Compare commits
7 Commits
eb8666ba40
...
e90f10fe47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e90f10fe47 | ||
|
|
25f55f728a | ||
|
|
d28cf7b538 | ||
|
|
81b3c112b4 | ||
|
|
410664fb0f | ||
|
|
849ef3c310 | ||
|
|
8e476f5658 |
43
src/apps/applets/migrations/0011_seed_wallet_shop_applet.py
Normal file
43
src/apps/applets/migrations/0011_seed_wallet_shop_applet.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Seed the wallet Shop applet — Chunk 2 of the wallet expansion sprint.
|
||||
|
||||
Locked spec from [[project-wallet-shop-expansion]]: 4 rows total in the
|
||||
wallet context (Shop atop, Balances + Tokens + Payment beneath); 12 cols
|
||||
in landscape (full-width row). Mimics the existing wallet applets'
|
||||
grid_cols=12 / grid_rows=3 shape.
|
||||
|
||||
`display_order` is NOT a field on Applet — applet ordering is dictated
|
||||
by the wallet template's include order in `_applets.html` + the applet
|
||||
slug alphabetical fallback in `applet_context()`. The template's include
|
||||
order is set in Chunk 4; this migration just ensures the row exists.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.update_or_create(
|
||||
slug="wallet-shop",
|
||||
defaults={
|
||||
"name": "Shop",
|
||||
"context": "wallet",
|
||||
"default_visible": True,
|
||||
"grid_cols": 12,
|
||||
"grid_rows": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="wallet-shop").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0010_rename_my_sign_applet"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed, unseed),
|
||||
]
|
||||
18
src/apps/applets/migrations/0012_applet_display_order.py
Normal file
18
src/apps/applets/migrations/0012_applet_display_order.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-05-22 05:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applets', '0011_seed_wallet_shop_applet'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='applet',
|
||||
name='display_order',
|
||||
field=models.PositiveSmallIntegerField(default=100),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Pin the wallet Shop applet atop the wallet row.
|
||||
|
||||
The 4-applet wallet layout (per [[project-wallet-shop-expansion]]) wants
|
||||
Shop first; the other 3 (Balances, Tokens, Payment) keep their historical
|
||||
order via the default `display_order=100` + PK tie-break.
|
||||
|
||||
Idempotent — `update_or_create(slug=…, defaults={display_order: 10})`
|
||||
also covers fresh DBs where `0011_seed_wallet_shop_applet` already ran.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="wallet-shop").update(display_order=10)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="wallet-shop").update(display_order=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0012_applet_display_order"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse),
|
||||
]
|
||||
@@ -18,6 +18,11 @@ class Applet(models.Model):
|
||||
default_visible = models.BooleanField(default=True)
|
||||
grid_cols = models.PositiveSmallIntegerField(default=12)
|
||||
grid_rows = models.PositiveSmallIntegerField(default=3)
|
||||
# Render-time sort key. Lower = earlier in the applets row. Default 100
|
||||
# gives every existing applet a tied position → falls back to PK insertion
|
||||
# order (the historical behavior), so this field is backwards-compatible.
|
||||
# Set to <100 to pin an applet ABOVE the rest (eg. wallet-shop = 10).
|
||||
display_order = models.PositiveSmallIntegerField(default=100)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -13,9 +13,12 @@ def apply_applet_toggle(user, context, checked_slugs):
|
||||
|
||||
def applet_context(user, context):
|
||||
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
||||
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
|
||||
# `display_order` (lower = earlier) is the primary sort key; `pk` tie-breaks
|
||||
# so applets at the default order=100 keep their historical insertion-order
|
||||
# rendering. New applets that want pinned positions set order < 100 in
|
||||
# their seed migration (eg. wallet-shop = 10 to render atop the wallet row).
|
||||
applets_qs = Applet.objects.filter(context=context).order_by("display_order", "pk")
|
||||
return [
|
||||
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
|
||||
for slug in applets
|
||||
if slug in applets
|
||||
{"applet": a, "visible": ua_map.get(a.pk, a.default_visible)}
|
||||
for a in applets_qs
|
||||
]
|
||||
|
||||
153
src/apps/dashboard/static/apps/dashboard/wallet-shop.js
Normal file
153
src/apps/dashboard/static/apps/dashboard/wallet-shop.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// wallet-shop.js — BUY-ITEM click handler for the wallet's Shop applet.
|
||||
//
|
||||
// Flow:
|
||||
// 1. User clicks a `.tt-buy-btn` inside a `.shop-tile`.
|
||||
// 2. We open the global guard portal (`window.showGuard`) w. a prompt
|
||||
// naming the item + price.
|
||||
// 3. On OK confirm → POST /dashboard/wallet/shop/buy {shop_item_slug} →
|
||||
// get {client_secret, purchase_id}.
|
||||
// 4. Stripe.js confirmCardPayment (handles 3DS natively).
|
||||
// 5. On Stripe success → POST /dashboard/wallet/shop/confirm {purchase_id}.
|
||||
// 6. Reload the wallet (tokens + balances update server-side via the
|
||||
// Purchase.fulfill() chain that fires from BOTH /shop/confirm AND
|
||||
// the /stripe/webhook handler — whichever lands first, idempotent).
|
||||
//
|
||||
// Disabled tiles (`.btn-disabled` on the BUY btn — eg already-owned BAND)
|
||||
// are a no-op: clicks don't open the guard portal.
|
||||
//
|
||||
// Module pattern matches the rest of the project (`Brief`, `SeaDeal`,
|
||||
// `StageCard`) — exposes a singleton `WalletShop` w. a tested public
|
||||
// `initWalletShop()` method.
|
||||
|
||||
const WalletShop = (function () {
|
||||
'use strict';
|
||||
|
||||
function _getCsrf() {
|
||||
const m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
async function _doBuy(slug, btn, shopRoot) {
|
||||
const pmId = shopRoot.dataset.defaultPaymentMethodId || '';
|
||||
const pubKey = shopRoot.dataset.stripePublishableKey || '';
|
||||
if (!pmId) {
|
||||
// No saved payment method — surface a friendly message + bail.
|
||||
// (Server-side this would 402, but we'd rather not waste the
|
||||
// round trip OR confuse the user w. a stripe-side error.)
|
||||
alert('Add a payment method below first.');
|
||||
return;
|
||||
}
|
||||
const buyRes = await fetch('/dashboard/wallet/shop/buy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': _getCsrf(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'shop_item_slug=' + encodeURIComponent(slug),
|
||||
});
|
||||
if (!buyRes.ok) {
|
||||
alert('Could not start purchase (' + buyRes.status + ').');
|
||||
return;
|
||||
}
|
||||
const { client_secret, purchase_id } = await buyRes.json();
|
||||
const stripe = window.Stripe(pubKey);
|
||||
const { error, paymentIntent } = await stripe.confirmCardPayment(
|
||||
client_secret, { payment_method: pmId },
|
||||
);
|
||||
if (error) {
|
||||
alert('Payment failed: ' + (error.message || 'unknown error'));
|
||||
return;
|
||||
}
|
||||
if (paymentIntent && paymentIntent.status === 'succeeded') {
|
||||
// Sync-confirm endpoint: belt-and-suspenders w. the webhook —
|
||||
// whichever lands first wins via Purchase.fulfill()'s idempotent
|
||||
// status guard. We always call it for the snappy UX.
|
||||
await fetch('/dashboard/wallet/shop/confirm', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': _getCsrf(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'purchase_id=' + encodeURIComponent(purchase_id),
|
||||
});
|
||||
// Reload to refresh the Tokens + Balances applets w. the
|
||||
// freshly-minted tokens + writs.
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function _onBuyClick(e) {
|
||||
const btn = e.target.closest('.tt-buy-btn');
|
||||
if (!btn) return;
|
||||
if (btn.classList.contains('btn-disabled')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// The btn may be the original (inside .shop-tile) OR the portal
|
||||
// clone (sibling of .wallet-page) — `closest('.shop-tile')` only
|
||||
// works on the former. Read every datum from the btn itself + look
|
||||
// up the shop root via document.querySelector (singleton).
|
||||
const shopRoot = document.querySelector('.wallet-shop');
|
||||
if (!shopRoot) return;
|
||||
const slug = btn.dataset.shopItemSlug;
|
||||
const name = btn.dataset.itemName || slug;
|
||||
const priceCents = parseInt(btn.dataset.priceCents || '0', 10);
|
||||
// Render "$1" / "$4" / "$20.50" — trim ".00" for whole-dollar prices.
|
||||
const dollars = priceCents / 100;
|
||||
const priceStr = (dollars === Math.floor(dollars))
|
||||
? '$' + dollars
|
||||
: '$' + dollars.toFixed(2);
|
||||
const message = 'Buy ' + name + ' for ' + priceStr + '?';
|
||||
if (typeof window.showGuard === 'function') {
|
||||
// Pin the wallet tooltip so the item card + microbtn stay
|
||||
// visible while the guard portal is open — otherwise the
|
||||
// cursor moving from the BUY btn down to the guard's OK/NVM
|
||||
// triggers the tooltip's mouseleave, leaving the guard's
|
||||
// "Buy {name} for ${price}?" prompt floating w. no referent.
|
||||
// Both callbacks (confirm + dismiss) unpin so the next
|
||||
// mouseleave (or the unpin-triggered _scheduleHide) dismisses
|
||||
// the tooltip after the user resolves the prompt.
|
||||
if (window.WalletTooltips && typeof window.WalletTooltips.pin === 'function') {
|
||||
window.WalletTooltips.pin();
|
||||
}
|
||||
window.showGuard(
|
||||
btn, message,
|
||||
function () { // onConfirm
|
||||
if (window.WalletTooltips && window.WalletTooltips.unpin) {
|
||||
window.WalletTooltips.unpin();
|
||||
}
|
||||
_doBuy(slug, btn, shopRoot);
|
||||
},
|
||||
function () { // onDismiss
|
||||
if (window.WalletTooltips && window.WalletTooltips.unpin) {
|
||||
window.WalletTooltips.unpin();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function initWalletShop() {
|
||||
const shopRoot = document.querySelector('.wallet-shop');
|
||||
if (!shopRoot) return;
|
||||
// Wired-state is DOM-keyed (`data-shop-wired`) rather than a
|
||||
// module-scope flag so per-test fixture rebuilds re-wire cleanly.
|
||||
// Calling twice on the SAME root is still a safe no-op (idempotent).
|
||||
if (shopRoot.dataset.shopWired === '1') return;
|
||||
// Triple delegation — the BUY btn click can come from:
|
||||
// (a) original tile (inside `.shop-tile`) — shopRoot listener
|
||||
// (b) cloned main portal — kept for legacy / non-mini-portal pages
|
||||
// (c) cloned mini portal (`#id_mini_tooltip_portal`) — production
|
||||
// path post-microtooltip refactor; the BUY btn lives in
|
||||
// `.tt-micro` which clones into the mini portal on hover.
|
||||
shopRoot.addEventListener('click', _onBuyClick);
|
||||
const portal = document.getElementById('id_tooltip_portal');
|
||||
if (portal) portal.addEventListener('click', _onBuyClick);
|
||||
const miniPortal = document.getElementById('id_mini_tooltip_portal');
|
||||
if (miniPortal) miniPortal.addEventListener('click', _onBuyClick);
|
||||
shopRoot.dataset.shopWired = '1';
|
||||
}
|
||||
|
||||
return { initWalletShop: initWalletShop };
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', WalletShop.initWalletShop);
|
||||
@@ -64,31 +64,113 @@ const initWallet = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// `WalletTooltips` module — exposes pin/unpin so other JS (eg. wallet-shop.js'
|
||||
// BUY-flow guard portal) can hold the wallet tooltip open across user
|
||||
// interactions that would otherwise dismiss it. The internals (hide
|
||||
// timer, _show/_hide helpers) live inside the singleton's IIFE — only
|
||||
// pin/unpin/initWalletTooltips are public.
|
||||
const WalletTooltips = (function () {
|
||||
'use strict';
|
||||
|
||||
let _hideTimer = null;
|
||||
let _pinned = false;
|
||||
const HIDE_DELAY_MS = 200;
|
||||
let _portal = null;
|
||||
let _miniPortal = null;
|
||||
|
||||
function _hideAll() {
|
||||
if (_portal) _portal.classList.remove('active');
|
||||
if (_miniPortal) _miniPortal.classList.remove('active');
|
||||
}
|
||||
|
||||
function _cancelHide() {
|
||||
if (_hideTimer) { clearTimeout(_hideTimer); _hideTimer = null; }
|
||||
}
|
||||
|
||||
function _scheduleHide() {
|
||||
// Pinned (eg. guard portal open) — suppress the hide. unpin() will
|
||||
// call _scheduleHide() again to dismiss after the guard closes.
|
||||
if (_pinned) return;
|
||||
_cancelHide();
|
||||
_hideTimer = setTimeout(() => { _hideAll(); _hideTimer = null; }, HIDE_DELAY_MS);
|
||||
}
|
||||
|
||||
function pin() { _pinned = true; _cancelHide(); }
|
||||
function unpin(){ _pinned = false; _scheduleHide(); }
|
||||
|
||||
function initWalletTooltips() {
|
||||
const portal = document.getElementById('id_tooltip_portal');
|
||||
if (!portal) return;
|
||||
_portal = portal;
|
||||
// Mini portal — used by shop tiles (BUY ITEM / "Already owned" pill).
|
||||
// Tokens applet tiles have no `.tt-micro` sibling so the mini stays
|
||||
// hidden on those hovers. Mirrors gameboard.html's mini portal.
|
||||
const miniPortal = document.getElementById('id_mini_tooltip_portal');
|
||||
_miniPortal = miniPortal;
|
||||
|
||||
document.querySelectorAll('.wallet-tokens .token').forEach(token => {
|
||||
const tooltip = token.querySelector('.tt');
|
||||
// Hover-persistence — keep the portal(s) open while the cursor moves
|
||||
// from tile → portal → mini-portal so users can click the BUY-ITEM
|
||||
// microbutton. A short hide delay covers the gap between
|
||||
// mouseleave-on-tile and mouseenter-on-portal; entering any of the
|
||||
// 3 zones cancels the hide.
|
||||
|
||||
function _show(anchor, tooltipHtml, microHtml) {
|
||||
_cancelHide();
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
portal.innerHTML = tooltipHtml;
|
||||
portal.classList.add('active');
|
||||
const halfW = portal.offsetWidth / 2;
|
||||
const rawLeft = rect.left + rect.width / 2;
|
||||
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
||||
portal.style.left = Math.round(clampedLeft) + 'px';
|
||||
portal.style.top = Math.round(rect.top) + 'px';
|
||||
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||
|
||||
if (miniPortal && microHtml) {
|
||||
miniPortal.innerHTML = microHtml;
|
||||
miniPortal.classList.add('active');
|
||||
// Pin mini-portal to the bottom-right of the main portal —
|
||||
// same anchor pattern as gameboard.js's gameKit tooltips.
|
||||
const mainRect = portal.getBoundingClientRect();
|
||||
miniPortal.style.left = '';
|
||||
miniPortal.style.right = Math.round(window.innerWidth - mainRect.right) + 'px';
|
||||
miniPortal.style.top = (mainRect.bottom + 4) + 'px';
|
||||
} else if (miniPortal) {
|
||||
miniPortal.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function _bindHover(anchor) {
|
||||
const tooltip = anchor.querySelector('.tt');
|
||||
if (!tooltip) return;
|
||||
// `.tt-micro` is a SIBLING of `.tt` (not a child) so it lives
|
||||
// alongside the main tooltip content without nesting — keeps the
|
||||
// BUY-ITEM btn visually distinct in the mini portal.
|
||||
const micro = anchor.querySelector(':scope > .tt-micro');
|
||||
const microHtml = micro ? micro.innerHTML : null;
|
||||
anchor.addEventListener('mouseenter', () => _show(anchor, tooltip.innerHTML, microHtml));
|
||||
anchor.addEventListener('mouseleave', _scheduleHide);
|
||||
}
|
||||
|
||||
token.addEventListener('mouseenter', () => {
|
||||
const rect = token.getBoundingClientRect();
|
||||
portal.innerHTML = tooltip.innerHTML;
|
||||
portal.classList.add('active');
|
||||
const halfW = portal.offsetWidth / 2;
|
||||
const rawLeft = rect.left + rect.width / 2;
|
||||
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
||||
portal.style.left = Math.round(clampedLeft) + 'px';
|
||||
portal.style.top = Math.round(rect.top) + 'px';
|
||||
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||
});
|
||||
document.querySelectorAll('.wallet-tokens .token, .wallet-shop .shop-tile')
|
||||
.forEach(_bindHover);
|
||||
|
||||
token.addEventListener('mouseleave', () => {
|
||||
portal.classList.remove('active');
|
||||
});
|
||||
});
|
||||
// Re-entering either portal cancels the pending hide — keeps the
|
||||
// microbutton clickable. Leaving either restarts the hide timer.
|
||||
portal.addEventListener('mouseenter', _cancelHide);
|
||||
portal.addEventListener('mouseleave', _scheduleHide);
|
||||
if (miniPortal) {
|
||||
miniPortal.addEventListener('mouseenter', _cancelHide);
|
||||
miniPortal.addEventListener('mouseleave', _scheduleHide);
|
||||
}
|
||||
}
|
||||
|
||||
return { initWalletTooltips: initWalletTooltips, pin: pin, unpin: unpin };
|
||||
})();
|
||||
|
||||
// Expose globally so wallet-shop.js can call WalletTooltips.pin/unpin
|
||||
// without depending on script-load order.
|
||||
window.WalletTooltips = WalletTooltips;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initWallet);
|
||||
document.addEventListener('DOMContentLoaded', initWalletTooltips);
|
||||
document.addEventListener('DOMContentLoaded', WalletTooltips.initWalletTooltips);
|
||||
338
src/apps/dashboard/tests/integrated/test_shop_views.py
Normal file
338
src/apps/dashboard/tests/integrated/test_shop_views.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""Shop view ITs — Chunk 3 of the wallet-expansion sprint.
|
||||
|
||||
Three endpoints under test:
|
||||
* `POST /dashboard/wallet/shop/buy` (`shop_buy`) — creates a Stripe
|
||||
PaymentIntent + a `Purchase` row in PENDING; returns
|
||||
`{client_secret, purchase_id}` for Stripe.js confirmCardPayment.
|
||||
* `POST /dashboard/wallet/shop/confirm` (`shop_confirm`) — sync follow-up
|
||||
after Stripe.js confirms client-side; retrieves the PI from Stripe,
|
||||
if `status=='succeeded'` calls `Purchase.fulfill()` (idempotent w. the
|
||||
webhook's parallel call).
|
||||
* `POST /stripe/webhook` (`stripe_webhook`) — async fulfillment fallback
|
||||
+ bulletproof for 3DS-completed cards. Verifies signature against
|
||||
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
|
||||
`Purchase.fulfill()` (same idempotent method as `shop_confirm`).
|
||||
|
||||
`apps.dashboard.views.stripe` is mocked across all three. The webhook
|
||||
view's signature verification is also mocked via
|
||||
`stripe.Webhook.construct_event`.
|
||||
"""
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User
|
||||
|
||||
|
||||
def _seed_starting_items():
|
||||
"""Mirror the seed-migration row shape so each TestCase starts w. a
|
||||
known catalog (TestCase rolls back the data migration, so the rows
|
||||
seeded by `0009_seed_shop_items` aren't there during tests)."""
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="tithe-1",
|
||||
defaults={
|
||||
"name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
|
||||
"icon": "fa-piggy-bank", "badge_text": "",
|
||||
"price_cents": 100, "granted_token_type": Token.TITHE,
|
||||
"granted_count": 1, "granted_writs": 12,
|
||||
"max_owned": None, "display_order": 10, "active": True,
|
||||
},
|
||||
)
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="band-1",
|
||||
defaults={
|
||||
"name": "Wristband", "description": "Admit All Entry",
|
||||
"shoptalk": "Unlimited free entry (BYOB)",
|
||||
"icon": "fa-ring", "badge_text": "",
|
||||
"price_cents": 2000, "granted_token_type": Token.BAND,
|
||||
"granted_count": 1, "granted_writs": 0,
|
||||
"max_owned": 1, "display_order": 30, "active": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ShopBuyViewTest(TestCase):
|
||||
def setUp(self):
|
||||
_seed_starting_items()
|
||||
self.user = User.objects.create(email="buyer@test.io")
|
||||
self.user.stripe_customer_id = "cus_buyer"
|
||||
self.user.save()
|
||||
PaymentMethod.objects.create(
|
||||
user=self.user, stripe_pm_id="pm_test_4242",
|
||||
last4="4242", brand="visa",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}
|
||||
)
|
||||
self.assertRedirects(
|
||||
response, "/?next=/dashboard/wallet/shop/buy",
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_success_creates_payment_intent_and_purchase(self, mock_stripe):
|
||||
mock_stripe.PaymentIntent.create.return_value = mock.Mock(
|
||||
id="pi_test_abc", client_secret="pi_test_abc_secret",
|
||||
)
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["client_secret"], "pi_test_abc_secret")
|
||||
purchase = Purchase.objects.get(pk=body["purchase_id"])
|
||||
self.assertEqual(purchase.user, self.user)
|
||||
self.assertEqual(purchase.shop_item.slug, "tithe-1")
|
||||
self.assertEqual(purchase.status, Purchase.PENDING)
|
||||
self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc")
|
||||
self.assertEqual(purchase.amount_cents, 100)
|
||||
self.assertEqual(purchase.granted_writs, 12)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_payment_intent_called_with_correct_args(self, mock_stripe):
|
||||
mock_stripe.PaymentIntent.create.return_value = mock.Mock(
|
||||
id="pi_a", client_secret="pi_a_secret",
|
||||
)
|
||||
self.client.post(
|
||||
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
|
||||
)
|
||||
kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
|
||||
self.assertEqual(kwargs["amount"], 100)
|
||||
self.assertEqual(kwargs["currency"], "usd")
|
||||
self.assertEqual(kwargs["customer"], "cus_buyer")
|
||||
self.assertEqual(kwargs["payment_method"], "pm_test_4242")
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_unknown_item_slug_returns_404(self, mock_stripe):
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/buy", {"shop_item_slug": "no-such-item"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_inactive_item_returns_404(self, mock_stripe):
|
||||
item = ShopItem.objects.get(slug="tithe-1")
|
||||
item.active = False
|
||||
item.save()
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_max_owned_violation_returns_409(self, mock_stripe):
|
||||
"""User already owns 1 BAND (max_owned=1) → buy refused w. 409."""
|
||||
Token.objects.create(user=self.user, token_type=Token.BAND)
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/buy", {"shop_item_slug": "band-1"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
# No PaymentIntent created
|
||||
mock_stripe.PaymentIntent.create.assert_not_called()
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_no_payment_method_returns_402(self, mock_stripe):
|
||||
"""User w. no saved PaymentMethod → 402 Payment Required."""
|
||||
self.user.payment_methods.all().delete()
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 402)
|
||||
mock_stripe.PaymentIntent.create.assert_not_called()
|
||||
|
||||
|
||||
class ShopConfirmViewTest(TestCase):
|
||||
def setUp(self):
|
||||
_seed_starting_items()
|
||||
self.user = User.objects.create(email="confirm@test.io")
|
||||
self.user.tokens.all().delete()
|
||||
self.user.refresh_from_db()
|
||||
self.client.force_login(self.user)
|
||||
self.tithe = ShopItem.objects.get(slug="tithe-1")
|
||||
self.purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe,
|
||||
stripe_payment_intent_id="pi_conf_1",
|
||||
amount_cents=100, granted_writs=12,
|
||||
)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
|
||||
)
|
||||
self.assertRedirects(
|
||||
response, "/?next=/dashboard/wallet/shop/confirm",
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_succeeded_pi_triggers_fulfill(self, mock_stripe):
|
||||
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.purchase.refresh_from_db()
|
||||
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||
)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_pending_pi_does_not_fulfill(self, mock_stripe):
|
||||
"""Stripe still processing → leave Purchase PENDING for the webhook."""
|
||||
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="processing")
|
||||
self.client.post(
|
||||
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
|
||||
)
|
||||
self.purchase.refresh_from_db()
|
||||
self.assertEqual(self.purchase.status, Purchase.PENDING)
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 0
|
||||
)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_idempotent_when_already_succeeded(self, mock_stripe):
|
||||
"""Webhook already fulfilled — confirm endpoint shouldn't double-mint."""
|
||||
self.purchase.fulfill()
|
||||
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
|
||||
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||
)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_other_users_purchase_returns_404(self, mock_stripe):
|
||||
"""A user trying to confirm someone else's PendingPurchase gets 404."""
|
||||
other = User.objects.create(email="other@test.io")
|
||||
other_purchase = Purchase.objects.create(
|
||||
user=other, shop_item=self.tithe,
|
||||
stripe_payment_intent_id="pi_other",
|
||||
amount_cents=100, granted_writs=12,
|
||||
)
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test_123")
|
||||
class StripeWebhookViewTest(TestCase):
|
||||
def setUp(self):
|
||||
_seed_starting_items()
|
||||
self.user = User.objects.create(email="webhook@test.io")
|
||||
self.user.tokens.all().delete()
|
||||
self.user.refresh_from_db()
|
||||
self.tithe = ShopItem.objects.get(slug="tithe-1")
|
||||
self.purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe,
|
||||
stripe_payment_intent_id="pi_wh_1",
|
||||
amount_cents=100, granted_writs=12,
|
||||
)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_signature_mismatch_returns_400(self, mock_stripe):
|
||||
mock_stripe.Webhook.construct_event.side_effect = ValueError("bad sig")
|
||||
response = self.client.post(
|
||||
"/stripe/webhook",
|
||||
data=b"{}",
|
||||
content_type="application/json",
|
||||
HTTP_STRIPE_SIGNATURE="t=0,v1=deadbeef",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_payment_intent_succeeded_triggers_fulfill(self, mock_stripe):
|
||||
# `Mock(intent_obj, ...)` returns a `dict()`-style object that supports
|
||||
# `event["type"]` AND `event["data"]["object"]["id"]` — match what the
|
||||
# webhook view will read.
|
||||
mock_stripe.Webhook.construct_event.return_value = {
|
||||
"type": "payment_intent.succeeded",
|
||||
"data": {"object": {
|
||||
"id": "pi_wh_1",
|
||||
"metadata": {"purchase_id": str(self.purchase.pk)},
|
||||
}},
|
||||
}
|
||||
response = self.client.post(
|
||||
"/stripe/webhook", data=b"{}",
|
||||
content_type="application/json",
|
||||
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.purchase.refresh_from_db()
|
||||
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||
)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_unknown_event_type_is_noop(self, mock_stripe):
|
||||
"""Stripe sends a lot of event types we don't care about (eg
|
||||
`charge.dispute.created`). Webhook view should return 2xx so
|
||||
Stripe doesn't retry, but NOT touch the Purchase."""
|
||||
mock_stripe.Webhook.construct_event.return_value = {
|
||||
"type": "charge.dispute.created",
|
||||
"data": {"object": {"id": "pi_wh_1"}},
|
||||
}
|
||||
response = self.client.post(
|
||||
"/stripe/webhook", data=b"{}",
|
||||
content_type="application/json",
|
||||
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.purchase.refresh_from_db()
|
||||
self.assertEqual(self.purchase.status, Purchase.PENDING)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_duplicate_delivery_is_idempotent(self, mock_stripe):
|
||||
"""Stripe may redeliver a webhook (network blip, our 5xx, etc.).
|
||||
Re-firing the same `payment_intent.succeeded` event must not
|
||||
double-mint tokens."""
|
||||
mock_stripe.Webhook.construct_event.return_value = {
|
||||
"type": "payment_intent.succeeded",
|
||||
"data": {"object": {
|
||||
"id": "pi_wh_1",
|
||||
"metadata": {"purchase_id": str(self.purchase.pk)},
|
||||
}},
|
||||
}
|
||||
self.client.post(
|
||||
"/stripe/webhook", data=b"{}",
|
||||
content_type="application/json",
|
||||
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
|
||||
)
|
||||
self.client.post(
|
||||
"/stripe/webhook", data=b"{}",
|
||||
content_type="application/json",
|
||||
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||
)
|
||||
|
||||
@mock.patch("apps.dashboard.views.stripe")
|
||||
def test_unknown_purchase_id_in_metadata_is_noop(self, mock_stripe):
|
||||
"""If metadata.purchase_id doesn't match any row, log + 200 (don't
|
||||
crash the webhook listener — Stripe would retry on 5xx)."""
|
||||
mock_stripe.Webhook.construct_event.return_value = {
|
||||
"type": "payment_intent.succeeded",
|
||||
"data": {"object": {
|
||||
"id": "pi_unknown",
|
||||
"metadata": {"purchase_id": "999999"},
|
||||
}},
|
||||
}
|
||||
response = self.client.post(
|
||||
"/stripe/webhook", data=b"{}",
|
||||
content_type="application/json",
|
||||
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.purchase.refresh_from_db()
|
||||
self.assertEqual(self.purchase.status, Purchase.PENDING)
|
||||
@@ -40,12 +40,101 @@ class WalletViewTest(TestCase):
|
||||
def test_wallet_page_shows_stripe_payment_element(self):
|
||||
[_] = self.parsed.cssselect("#id_stripe_payment_element")
|
||||
|
||||
def test_wallet_page_shows_tithe_token_shop(self):
|
||||
[_] = self.parsed.cssselect("#id_tithe_token_shop")
|
||||
# Note: the legacy `#id_tithe_token_shop` HTML in Balances was
|
||||
# superseded by the dedicated Shop applet in Chunk 5 of
|
||||
# [[project-wallet-shop-expansion]]. Shop-applet coverage lives in
|
||||
# `WalletTokensAppletAllTrinketsVisibleTest` below + `test_shop_models.py`
|
||||
# + `test_shop_views.py`.
|
||||
|
||||
def test_tithe_token_shop_shows_bundle(self):
|
||||
bundles = self.parsed.cssselect("#id_tithe_token_shop .token-bundle")
|
||||
self.assertGreater(len(bundles), 0)
|
||||
|
||||
class WalletTokensAppletAllTrinketsVisibleTest(TestCase):
|
||||
"""Chunk 1 of the Shop applet rollout (2026-05-22) — the Tokens applet
|
||||
in `wallet.html` must show every owned trinket-as-token type at once.
|
||||
Pre-Chunk-1 the template's `{% if pass_token %} ... {% elif band %}
|
||||
... {% elif coin %}` chain hid two of the three from any user holding
|
||||
a higher-priority trinket — bad UX since all three are usable at the
|
||||
gate (per [[feedback-equip-slot-gates-trinket-use]], the user picks
|
||||
WHICH one fires via the equip slot)."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="multitoken@test.io", is_staff=True)
|
||||
# Auto-COIN (equipped) + FREE created by post_save signal; PASS auto-
|
||||
# granted by the is_staff branch of the same signal. Add the rest.
|
||||
Token.objects.create(user=self.user, token_type=Token.BAND)
|
||||
Token.objects.create(user=self.user, token_type=Token.CARTE)
|
||||
Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get("/dashboard/wallet/")
|
||||
self.parsed = lxml.html.fromstring(response.content)
|
||||
|
||||
def test_wallet_shows_pass_token(self):
|
||||
[_] = self.parsed.cssselect("#id_pass_token")
|
||||
|
||||
def test_wallet_shows_band_token(self):
|
||||
[_] = self.parsed.cssselect("#id_band_token")
|
||||
|
||||
def test_wallet_shows_coin_on_a_string(self):
|
||||
[_] = self.parsed.cssselect("#id_coin_on_a_string")
|
||||
|
||||
def test_wallet_shows_carte_token(self):
|
||||
[_] = self.parsed.cssselect("#id_carte_token")
|
||||
|
||||
def test_wallet_shows_free_token(self):
|
||||
[_] = self.parsed.cssselect("#id_free_token")
|
||||
|
||||
def test_wallet_shows_tithe_token(self):
|
||||
[_] = self.parsed.cssselect("#id_tithe_token")
|
||||
|
||||
def test_view_context_passes_carte(self):
|
||||
"""Defense-in-depth: not just the template but the view context too —
|
||||
a renamed/refactored template should still receive `carte` in ctx."""
|
||||
response = self.client.get("/dashboard/wallet/")
|
||||
self.assertEqual(response.context["carte"].token_type, Token.CARTE)
|
||||
|
||||
def test_view_context_passes_band(self):
|
||||
response = self.client.get("/dashboard/wallet/")
|
||||
self.assertEqual(response.context["band"].token_type, Token.BAND)
|
||||
|
||||
def test_non_staff_user_with_carte_still_sees_carte(self):
|
||||
"""CARTE has no `is_staff` gating (unlike PASS) — a regular gamer
|
||||
holding a CARTE must see it in the Tokens applet."""
|
||||
non_staff = User.objects.create(email="grunt@test.io")
|
||||
Token.objects.create(user=non_staff, token_type=Token.CARTE)
|
||||
self.client.force_login(non_staff)
|
||||
response = self.client.get("/dashboard/wallet/")
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
[_] = parsed.cssselect("#id_carte_token")
|
||||
|
||||
|
||||
class WalletAppletOrderTest(TestCase):
|
||||
"""The wallet row renders Shop first, then Balances/Tokens/Payment in
|
||||
their historical insertion order — pinned via `Applet.display_order`
|
||||
(lower = earlier; default 100 + PK tie-break preserves the legacy
|
||||
order for the rest). Bug-prevention pin: a future migration that
|
||||
renames or reseeds applets must keep wallet-shop at order < 100.
|
||||
See [[project-wallet-shop-expansion]] for the locked layout spec."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="layout@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_shop_applet_renders_first_in_wallet_row(self):
|
||||
response = self.client.get("/dashboard/wallet/")
|
||||
html = response.content.decode()
|
||||
shop_pos = html.find('id="id_wallet_shop"')
|
||||
balances_pos = html.find('id="id_wallet_balances"')
|
||||
tokens_pos = html.find('id_writs_balance') # inside balances applet
|
||||
# Shop's id_wallet_shop appears before Balances' id_wallet_balances
|
||||
self.assertGreater(shop_pos, 0)
|
||||
self.assertGreater(balances_pos, 0)
|
||||
self.assertLess(shop_pos, balances_pos)
|
||||
self.assertLess(shop_pos, tokens_pos)
|
||||
|
||||
def test_shop_applet_first_in_context_list(self):
|
||||
"""View-context shape pin: `applets` is a list ordered Shop-first."""
|
||||
response = self.client.get("/dashboard/wallet/")
|
||||
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||
self.assertEqual(slugs[0], "wallet-shop")
|
||||
|
||||
|
||||
class WalletPassTokenVisibilityTest(TestCase):
|
||||
|
||||
@@ -10,6 +10,8 @@ urlpatterns = [
|
||||
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
|
||||
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
|
||||
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
|
||||
path('wallet/shop/buy', views.shop_buy, name='shop_buy'),
|
||||
path('wallet/shop/confirm', views.shop_confirm, name='shop_confirm'),
|
||||
path('kit-bag/', views.kit_bag, name='kit_bag'),
|
||||
path('sky/', views.sky_view, name='sky'),
|
||||
path('sky/preview', views.sky_preview, name='sky_preview'),
|
||||
|
||||
@@ -13,12 +13,12 @@ from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirec
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from apps.drama.models import Note
|
||||
from apps.epic.utils import _compute_distinctions
|
||||
from apps.lyric.models import PaymentMethod, Token, User, Wallet, is_reserved_username
|
||||
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username
|
||||
|
||||
|
||||
APPLET_ORDER = ["wallet", "username", "palette"]
|
||||
@@ -155,6 +155,18 @@ def toggle_applets(request):
|
||||
})
|
||||
return redirect("home")
|
||||
|
||||
def _shop_items_for(user):
|
||||
"""Decorate the active ShopItem catalog w. per-user availability so the
|
||||
template can render `.btn-disabled` + 'Already owned' microtooltip
|
||||
for `max_owned`-capped items the user already holds. Items are returned
|
||||
in `display_order` ASC (matches the seeded `tithe-1` < `tithe-5` < `band-1`)."""
|
||||
items = []
|
||||
for item in ShopItem.objects.filter(active=True).order_by("display_order", "slug"):
|
||||
item.available = item.is_available_for(user)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@ensure_csrf_cookie
|
||||
def wallet(request):
|
||||
@@ -162,11 +174,17 @@ def wallet(request):
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at"))
|
||||
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
||||
shop_items = _shop_items_for(request.user)
|
||||
default_pm = request.user.payment_methods.order_by("-pk").first()
|
||||
return render(request, "apps/dashboard/wallet.html", {
|
||||
"wallet": request.user.wallet,
|
||||
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
||||
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
|
||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
||||
"shop_items": shop_items,
|
||||
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
|
||||
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
||||
"free_tokens": free_tokens,
|
||||
"tithe_tokens": tithe_tokens,
|
||||
"free_count": len(free_tokens),
|
||||
@@ -198,12 +216,17 @@ def toggle_wallet_applets(request):
|
||||
checked = request.POST.getlist("applets")
|
||||
apply_applet_toggle(request.user, "wallet", checked)
|
||||
if request.headers.get("HX-Request"):
|
||||
default_pm = request.user.payment_methods.order_by("-pk").first()
|
||||
return render(request, "apps/wallet/_partials/_applets.html", {
|
||||
"applets": applet_context(request.user, "wallet"),
|
||||
"wallet": request.user.wallet,
|
||||
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
||||
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
|
||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
||||
"shop_items": _shop_items_for(request.user),
|
||||
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
|
||||
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
||||
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
||||
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
||||
})
|
||||
@@ -238,6 +261,146 @@ def save_payment_method(request):
|
||||
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
|
||||
|
||||
|
||||
# ── Shop: PaymentIntent flow ─────────────────────────────────────────────────
|
||||
# Three endpoints split fulfillment responsibility:
|
||||
# /shop/buy creates a Purchase (PENDING) + a Stripe PaymentIntent.
|
||||
# Returns client_secret so Stripe.js can confirmCardPayment
|
||||
# (handles 3DS natively).
|
||||
# /shop/confirm sync follow-up after Stripe.js confirms client-side. Pulls
|
||||
# PI status from Stripe; if SUCCEEDED, calls Purchase.fulfill()
|
||||
# immediately (faster UX than waiting for the webhook round-trip).
|
||||
# /stripe/webhook async fulfillment from Stripe's webhook delivery. Same
|
||||
# Purchase.fulfill() call — whichever (confirm or webhook)
|
||||
# lands first wins; the other becomes a no-op via fulfill()'s
|
||||
# idempotent guard.
|
||||
#
|
||||
# Decisions locked 2026-05-21 in [[project-wallet-shop-expansion]]:
|
||||
# * Webhook is THE authoritative source for fulfillment (resilient to 3DS,
|
||||
# network drops, browser closes during checkout).
|
||||
# * Confirm endpoint is a UX-speedup belt-and-suspenders; never required.
|
||||
# * Webhook idempotency via Purchase.fulfill()'s status==SUCCEEDED guard.
|
||||
# * No STRIPE_LIVE_MODE setting — env-var swap is all that's needed.
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def shop_buy(request):
|
||||
"""Create a Stripe PaymentIntent + a PENDING Purchase row.
|
||||
|
||||
Body: `shop_item_slug` (form-encoded).
|
||||
Returns: 200 `{client_secret, purchase_id}` on success;
|
||||
402 if the user has no saved PaymentMethod;
|
||||
404 if the slug doesn't match an active ShopItem;
|
||||
409 if the item's max_owned cap is reached for this user.
|
||||
"""
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
slug = request.POST.get("shop_item_slug", "")
|
||||
item = ShopItem.objects.filter(slug=slug, active=True).first()
|
||||
if item is None:
|
||||
return HttpResponse(status=404)
|
||||
if not item.is_available_for(request.user):
|
||||
return HttpResponse(status=409)
|
||||
pm = request.user.payment_methods.order_by("-pk").first()
|
||||
if pm is None:
|
||||
return HttpResponse(status=402)
|
||||
intent = stripe.PaymentIntent.create(
|
||||
amount=item.price_cents,
|
||||
currency="usd",
|
||||
customer=request.user.stripe_customer_id,
|
||||
payment_method=pm.stripe_pm_id,
|
||||
# `automatic_payment_methods` so Stripe.js picks the right confirm
|
||||
# method (cards, wallets, etc.) without us hard-coding payment-method-
|
||||
# type plumbing. `allow_redirects=never` keeps the 3DS challenge in
|
||||
# the same window — Stripe.js handles the modal natively.
|
||||
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
|
||||
metadata={
|
||||
# Webhook handler looks up the Purchase by this on
|
||||
# `payment_intent.succeeded`. Belt-and-suspenders w. looking up
|
||||
# by `stripe_payment_intent_id` (also unique).
|
||||
"purchase_id": "_pending_", # overwritten after Purchase.save() below
|
||||
},
|
||||
)
|
||||
purchase = Purchase.objects.create(
|
||||
user=request.user,
|
||||
shop_item=item,
|
||||
stripe_payment_intent_id=intent.id,
|
||||
amount_cents=item.price_cents,
|
||||
granted_writs=item.granted_writs,
|
||||
)
|
||||
# Now we have purchase.pk — backfill the metadata on the PI so the
|
||||
# webhook handler can resolve back to it.
|
||||
stripe.PaymentIntent.modify(
|
||||
intent.id, metadata={"purchase_id": str(purchase.pk)},
|
||||
)
|
||||
return JsonResponse({
|
||||
"client_secret": intent.client_secret,
|
||||
"purchase_id": purchase.pk,
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def shop_confirm(request):
|
||||
"""Sync follow-up after Stripe.js confirms client-side. Polls the PI
|
||||
once + fulfills if SUCCEEDED. Idempotent w. the webhook handler via
|
||||
`Purchase.fulfill()`'s status guard.
|
||||
|
||||
Body: `purchase_id` (form-encoded).
|
||||
Returns: 200 always (sync fulfillment is best-effort; webhook is
|
||||
authoritative). 404 if the purchase doesn't belong to this user.
|
||||
"""
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
purchase_id = request.POST.get("purchase_id")
|
||||
purchase = Purchase.objects.filter(
|
||||
pk=purchase_id, user=request.user,
|
||||
).first()
|
||||
if purchase is None:
|
||||
return HttpResponse(status=404)
|
||||
if purchase.status != Purchase.SUCCEEDED:
|
||||
intent = stripe.PaymentIntent.retrieve(purchase.stripe_payment_intent_id)
|
||||
if intent.status == "succeeded":
|
||||
purchase.fulfill()
|
||||
return JsonResponse({"status": purchase.status})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def stripe_webhook(request):
|
||||
"""Stripe webhook listener. Verifies signature against
|
||||
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
|
||||
`Purchase.fulfill()` (idempotent w. `/shop/confirm`).
|
||||
|
||||
Always returns 2xx (even on unknown event types or already-fulfilled
|
||||
purchases) — Stripe retries on 5xx, which would just deliver the same
|
||||
event repeatedly. 4xx is reserved for signature mismatch (a genuine
|
||||
auth failure that Stripe should NOT retry).
|
||||
"""
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
payload = request.body
|
||||
sig_header = request.headers.get("Stripe-Signature", "")
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET,
|
||||
)
|
||||
except (ValueError, Exception) as e:
|
||||
# ValueError = invalid payload; SignatureVerificationError = bad sig.
|
||||
# Either way, refuse — Stripe will alert if it can't deliver.
|
||||
if isinstance(e, ValueError) or "Signature" in type(e).__name__:
|
||||
return HttpResponse(status=400)
|
||||
raise
|
||||
if event["type"] == "payment_intent.succeeded":
|
||||
intent = event["data"]["object"]
|
||||
purchase_id = intent.get("metadata", {}).get("purchase_id")
|
||||
purchase = None
|
||||
if purchase_id and purchase_id.isdigit():
|
||||
purchase = Purchase.objects.filter(pk=int(purchase_id)).first()
|
||||
# Fall-back lookup by PI ID in case metadata's missing for any reason.
|
||||
if purchase is None:
|
||||
purchase = Purchase.objects.filter(
|
||||
stripe_payment_intent_id=intent.get("id", ""),
|
||||
).first()
|
||||
if purchase is not None:
|
||||
purchase.fulfill()
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
# ── My Sky (personal natal chart) ────────────────────────────────────────────
|
||||
|
||||
def _sky_preview_data(request):
|
||||
|
||||
54
src/apps/lyric/migrations/0008_shopitem_purchase.py
Normal file
54
src/apps/lyric/migrations/0008_shopitem_purchase.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 6.0 on 2026-05-22 03:09
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lyric', '0007_alter_token_token_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ShopItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('icon', models.CharField(max_length=50)),
|
||||
('badge_text', models.CharField(blank=True, default='', max_length=8)),
|
||||
('price_cents', models.PositiveIntegerField()),
|
||||
('granted_token_type', models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass'), ('band', 'Wristband'), ('carte', 'Carte Blanche')], max_length=8)),
|
||||
('granted_count', models.PositiveSmallIntegerField(default=1)),
|
||||
('granted_writs', models.PositiveIntegerField(default=0)),
|
||||
('max_owned', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('display_order', models.PositiveSmallIntegerField(default=100)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['display_order', 'slug'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Purchase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stripe_payment_intent_id', models.CharField(max_length=255, unique=True)),
|
||||
('status', models.CharField(choices=[('PENDING', 'Pending'), ('SUCCEEDED', 'Succeeded'), ('FAILED', 'Failed'), ('REFUNDED', 'Refunded')], default='PENDING', max_length=10)),
|
||||
('amount_cents', models.PositiveIntegerField()),
|
||||
('granted_writs', models.PositiveIntegerField(default=0)),
|
||||
('granted_token_ids', models.JSONField(blank=True, default=list)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('succeeded_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchases', to=settings.AUTH_USER_MODEL)),
|
||||
('shop_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchases', to='lyric.shopitem')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
80
src/apps/lyric/migrations/0009_seed_shop_items.py
Normal file
80
src/apps/lyric/migrations/0009_seed_shop_items.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Seed the 3 starting catalog items for the wallet Shop applet.
|
||||
|
||||
Locked spec from [[project-wallet-shop-expansion]]:
|
||||
tithe-1 — $1 → 1 × TITHE + 144 writs (no cap)
|
||||
tithe-5 — $4 → 5 × TITHE + 750 writs (no cap; badge "×5")
|
||||
band-1 — $20 → 1 × BAND + 0 writs (max_owned=1)
|
||||
|
||||
Pricing migration-safe — admin can still adjust via Django admin without
|
||||
needing a code change; this is the floor / initial offer.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
SHOP_ITEMS = [
|
||||
{
|
||||
"slug": "tithe-1",
|
||||
"name": "Tithe Token",
|
||||
"description": "1 Tithe Token + 144 Writs",
|
||||
"icon": "fa-piggy-bank",
|
||||
"badge_text": "",
|
||||
"price_cents": 100,
|
||||
"granted_token_type": "tithe",
|
||||
"granted_count": 1,
|
||||
"granted_writs": 144,
|
||||
"max_owned": None,
|
||||
"display_order": 10,
|
||||
},
|
||||
{
|
||||
"slug": "tithe-5",
|
||||
"name": "Tithe Bundle",
|
||||
"description": "5 Tithe Tokens + 750 Writs",
|
||||
"icon": "fa-piggy-bank",
|
||||
"badge_text": "×5",
|
||||
"price_cents": 400,
|
||||
"granted_token_type": "tithe",
|
||||
"granted_count": 5,
|
||||
"granted_writs": 750,
|
||||
"max_owned": None,
|
||||
"display_order": 20,
|
||||
},
|
||||
{
|
||||
"slug": "band-1",
|
||||
"name": "Wristband",
|
||||
"description": "Admit All Entry — unlimited free entry (BYOB)",
|
||||
"icon": "fa-ring",
|
||||
"badge_text": "",
|
||||
"price_cents": 2000,
|
||||
"granted_token_type": "band",
|
||||
"granted_count": 1,
|
||||
"granted_writs": 0,
|
||||
"max_owned": 1,
|
||||
"display_order": 30,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_forward(apps, schema_editor):
|
||||
ShopItem = apps.get_model("lyric", "ShopItem")
|
||||
for spec in SHOP_ITEMS:
|
||||
ShopItem.objects.update_or_create(
|
||||
slug=spec["slug"],
|
||||
defaults={k: v for k, v in spec.items() if k != "slug"},
|
||||
)
|
||||
|
||||
|
||||
def seed_reverse(apps, schema_editor):
|
||||
ShopItem = apps.get_model("lyric", "ShopItem")
|
||||
ShopItem.objects.filter(slug__in=[s["slug"] for s in SHOP_ITEMS]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("lyric", "0008_shopitem_purchase"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_forward, seed_reverse),
|
||||
]
|
||||
57
src/apps/lyric/migrations/0010_repricing_tithe_writs.py
Normal file
57
src/apps/lyric/migrations/0010_repricing_tithe_writs.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Re-balance the Tithe shop items' writs payout.
|
||||
|
||||
User-locked 2026-05-22: `tithe-1` drops 144 → 12 Writs, `tithe-5` drops
|
||||
750 → 60 Writs. Description strings updated in lockstep so the tooltip
|
||||
prose tracks the new numbers.
|
||||
|
||||
`granted_token_type`, `granted_count`, `price_cents`, `max_owned`,
|
||||
`display_order` all unchanged. Only the writs grant + the description
|
||||
text shift.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
_UPDATES = [
|
||||
{
|
||||
"slug": "tithe-1",
|
||||
"description": "1 Tithe Token + 12 Writs",
|
||||
"granted_writs": 12,
|
||||
},
|
||||
{
|
||||
"slug": "tithe-5",
|
||||
"description": "5 Tithe Tokens + 60 Writs",
|
||||
"granted_writs": 60,
|
||||
},
|
||||
]
|
||||
_OLD = [
|
||||
{"slug": "tithe-1", "description": "1 Tithe Token + 144 Writs", "granted_writs": 144},
|
||||
{"slug": "tithe-5", "description": "5 Tithe Tokens + 750 Writs", "granted_writs": 750},
|
||||
]
|
||||
|
||||
|
||||
def _apply(apps, specs):
|
||||
ShopItem = apps.get_model("lyric", "ShopItem")
|
||||
for spec in specs:
|
||||
ShopItem.objects.filter(slug=spec["slug"]).update(
|
||||
description=spec["description"],
|
||||
granted_writs=spec["granted_writs"],
|
||||
)
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
_apply(apps, _UPDATES)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
_apply(apps, _OLD)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("lyric", "0009_seed_shop_items"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse),
|
||||
]
|
||||
18
src/apps/lyric/migrations/0011_shopitem_shoptalk.py
Normal file
18
src/apps/lyric/migrations/0011_shopitem_shoptalk.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-05-22 06:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lyric', '0010_repricing_tithe_writs'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shopitem',
|
||||
name='shoptalk',
|
||||
field=models.CharField(blank=True, default='', max_length=200),
|
||||
),
|
||||
]
|
||||
40
src/apps/lyric/migrations/0012_seed_shop_shoptalk.py
Normal file
40
src/apps/lyric/migrations/0012_seed_shop_shoptalk.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Populate `shoptalk` for existing shop items + split BAND's description.
|
||||
|
||||
Pre-migration: `band-1.description` carried both the game function
|
||||
("Admit All Entry") + the italic flavor ("unlimited free entry (BYOB)")
|
||||
crammed onto one line. The wallet shop tooltip now uses the DRY four-
|
||||
slot pattern of `.tt-title` / `.tt-description` / `.tt-shoptalk` /
|
||||
`.tt-expiry` — same classes the Tokens row already styles — so the
|
||||
flavor line moves to its own `shoptalk` field, mirroring how
|
||||
`Token.tooltip_shoptalk` separates flavor from description.
|
||||
|
||||
Tithes don't carry shoptalk (their Token tooltips don't either).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
ShopItem = apps.get_model("lyric", "ShopItem")
|
||||
ShopItem.objects.filter(slug="band-1").update(
|
||||
description="Admit All Entry",
|
||||
shoptalk="Unlimited free entry (BYOB)",
|
||||
)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
ShopItem = apps.get_model("lyric", "ShopItem")
|
||||
ShopItem.objects.filter(slug="band-1").update(
|
||||
description="Admit All Entry — unlimited free entry (BYOB)",
|
||||
shoptalk="",
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("lyric", "0011_shopitem_shoptalk"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse),
|
||||
]
|
||||
@@ -329,6 +329,143 @@ class PaymentMethod(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} ....{self.last4}"
|
||||
|
||||
|
||||
class ShopItem(models.Model):
|
||||
"""A purchasable bundle in the wallet's Shop applet — admin-managed
|
||||
catalog. Each row defines (price → granted Tokens + writs); the
|
||||
`Purchase.fulfill()` flow mints `granted_count` tokens of
|
||||
`granted_token_type` + bumps `Wallet.writs` by `granted_writs`.
|
||||
|
||||
See [[project-wallet-shop-expansion]] for the broader design + the
|
||||
3 starting catalog items (tithe-1, tithe-5, band-1) seeded in
|
||||
`lyric/0009_seed_shop_items`."""
|
||||
|
||||
slug = models.SlugField(unique=True)
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True, default="")
|
||||
# `shoptalk` is the italic flavor line that mirrors `Token.tooltip_shoptalk` —
|
||||
# rendered via the `.tt-shoptalk` SCSS class (DRY w. the wallet's Token row).
|
||||
# Blank → the `{% if item.shoptalk %}` slot in the template is skipped.
|
||||
shoptalk = models.CharField(max_length=200, blank=True, default="")
|
||||
icon = models.CharField(max_length=50) # FA icon class (eg "fa-piggy-bank")
|
||||
badge_text = models.CharField(max_length=8, blank=True, default="") # eg "×5"; "" = no badge
|
||||
price_cents = models.PositiveIntegerField()
|
||||
granted_token_type = models.CharField(
|
||||
max_length=8, choices=Token.TOKEN_TYPE_CHOICES,
|
||||
)
|
||||
granted_count = models.PositiveSmallIntegerField(default=1)
|
||||
granted_writs = models.PositiveIntegerField(default=0)
|
||||
# `max_owned=None` → unlimited stock per user. `max_owned=1` → BAND-style
|
||||
# "you can only have one of these" — the shop UI disables BUY w. an
|
||||
# "Already owned" microtooltip when the user's owned-count of the granted
|
||||
# token type has reached this cap.
|
||||
max_owned = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
display_order = models.PositiveSmallIntegerField(default=100)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["display_order", "slug"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def is_available_for(self, user):
|
||||
"""True iff the user can purchase another of this item right now.
|
||||
Honors `max_owned` (compares to user's owned-count of the granted
|
||||
token type). Items w. `max_owned=None` are always available."""
|
||||
if self.max_owned is None:
|
||||
return True
|
||||
owned = user.tokens.filter(token_type=self.granted_token_type).count()
|
||||
return owned < self.max_owned
|
||||
|
||||
def price_display(self):
|
||||
"""Render-ready dollar string for tooltips. Cents trimmed for whole
|
||||
dollars; otherwise two decimals."""
|
||||
dollars = self.price_cents / 100
|
||||
if dollars == int(dollars):
|
||||
return f"${int(dollars)}"
|
||||
return f"${dollars:.2f}"
|
||||
|
||||
def tooltip_expiry(self):
|
||||
"""All shop items are eternal stock (no time-bound listings yet) so
|
||||
the tooltip's `.tt-expiry` slot always shows 'no expiry' — same
|
||||
red-callout styling as PASS/BAND/CARTE token tooltips. If a future
|
||||
seasonal item needs a real expiry, override on the row + return
|
||||
the formatted string here."""
|
||||
return "no expiry"
|
||||
|
||||
|
||||
class Purchase(models.Model):
|
||||
"""Audit-trail row for one shop transaction. Created at PENDING on
|
||||
Stripe PaymentIntent creation, advanced to SUCCEEDED via `fulfill()`
|
||||
(called from EITHER the synchronous `/shop/confirm` view OR the
|
||||
`/stripe/webhook` handler — whichever wins). `fulfill()` is idempotent
|
||||
so the race is harmless.
|
||||
|
||||
`granted_token_ids` snapshots the PKs of every Token row this purchase
|
||||
minted so we can audit / refund / rebuild later without re-deriving
|
||||
from `created_at`."""
|
||||
|
||||
PENDING = "PENDING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
REFUNDED = "REFUNDED"
|
||||
STATUS_CHOICES = [
|
||||
(PENDING, "Pending"),
|
||||
(SUCCEEDED, "Succeeded"),
|
||||
(FAILED, "Failed"),
|
||||
(REFUNDED, "Refunded"),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="purchases")
|
||||
shop_item = models.ForeignKey(ShopItem, on_delete=models.PROTECT, related_name="purchases")
|
||||
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
|
||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
|
||||
amount_cents = models.PositiveIntegerField() # snapshot — ShopItem price may change later
|
||||
granted_writs = models.PositiveIntegerField(default=0) # snapshot
|
||||
granted_token_ids = models.JSONField(default=list, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
succeeded_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Purchase({self.user_id}, {self.shop_item.slug}, {self.status})"
|
||||
|
||||
def fulfill(self):
|
||||
"""Mint tokens + grant writs. Idempotent — re-firing on a row that's
|
||||
already SUCCEEDED is a safe no-op (the webhook + the sync
|
||||
`/shop/confirm` view both call this; whichever lands first wins).
|
||||
|
||||
Failures elsewhere shouldn't reach this method — `status=FAILED`
|
||||
rows stay FAILED + don't fulfill."""
|
||||
from django.db import transaction
|
||||
if self.status == self.SUCCEEDED:
|
||||
return
|
||||
if self.status not in (self.PENDING,):
|
||||
# FAILED / REFUNDED — refuse to fulfill.
|
||||
return
|
||||
item = self.shop_item
|
||||
with transaction.atomic():
|
||||
granted_ids = []
|
||||
for _ in range(item.granted_count):
|
||||
t = Token.objects.create(
|
||||
user=self.user,
|
||||
token_type=item.granted_token_type,
|
||||
)
|
||||
granted_ids.append(t.pk)
|
||||
if self.granted_writs:
|
||||
wallet = self.user.wallet
|
||||
wallet.writs = wallet.writs + self.granted_writs
|
||||
wallet.save(update_fields=["writs"])
|
||||
self.granted_token_ids = granted_ids
|
||||
self.status = self.SUCCEEDED
|
||||
self.succeeded_at = timezone.now()
|
||||
self.save(update_fields=[
|
||||
"granted_token_ids", "status", "succeeded_at",
|
||||
])
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
||||
|
||||
295
src/apps/lyric/tests/integrated/test_shop_models.py
Normal file
295
src/apps/lyric/tests/integrated/test_shop_models.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Shop model ITs — Chunk 2 of the wallet-expansion sprint.
|
||||
|
||||
Pins:
|
||||
* `ShopItem` row shape (slug, name, prose, icon, badge, pricing,
|
||||
granted-token spec, max_owned, display_order, active).
|
||||
* `Purchase` row shape + the `fulfill()` method's idempotent token-
|
||||
mint + writs-grant semantics.
|
||||
* `ShopItem.is_available_for(user)` w/ `max_owned` enforcement.
|
||||
* Seeded catalog: `tithe-1`, `tithe-5`, `band-1` present + correctly
|
||||
configured per the locked decisions in [[project-wallet-shop-
|
||||
expansion]].
|
||||
* Seeded `wallet-shop` Applet present.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.lyric.models import Purchase, ShopItem, Token, User
|
||||
|
||||
|
||||
class ShopItemModelTest(TestCase):
|
||||
def test_create_shopitem_minimal(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe",
|
||||
name="Probe",
|
||||
price_cents=100,
|
||||
granted_token_type=Token.TITHE,
|
||||
granted_count=1,
|
||||
)
|
||||
self.assertEqual(item.slug, "probe")
|
||||
self.assertEqual(item.price_cents, 100)
|
||||
self.assertEqual(item.granted_count, 1)
|
||||
|
||||
def test_granted_writs_defaults_to_zero(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe",
|
||||
name="Probe",
|
||||
price_cents=100,
|
||||
granted_token_type=Token.TITHE,
|
||||
granted_count=1,
|
||||
)
|
||||
self.assertEqual(item.granted_writs, 0)
|
||||
|
||||
def test_max_owned_defaults_to_null(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe",
|
||||
name="Probe",
|
||||
price_cents=100,
|
||||
granted_token_type=Token.TITHE,
|
||||
granted_count=1,
|
||||
)
|
||||
self.assertIsNone(item.max_owned)
|
||||
|
||||
def test_active_defaults_to_true(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe",
|
||||
name="Probe",
|
||||
price_cents=100,
|
||||
granted_token_type=Token.TITHE,
|
||||
granted_count=1,
|
||||
)
|
||||
self.assertTrue(item.active)
|
||||
|
||||
def test_str_returns_name(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe", name="Wristband", price_cents=2000,
|
||||
granted_token_type=Token.BAND, granted_count=1,
|
||||
)
|
||||
self.assertEqual(str(item), "Wristband")
|
||||
|
||||
def test_shoptalk_defaults_to_blank(self):
|
||||
"""Most items have no shoptalk (italic flavor line) — defaults to ""
|
||||
so the tooltip's `{% if item.shoptalk %}` skips the slot cleanly."""
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe", name="Probe", price_cents=100,
|
||||
granted_token_type=Token.TITHE, granted_count=1,
|
||||
)
|
||||
self.assertEqual(item.shoptalk, "")
|
||||
|
||||
def test_tooltip_expiry_returns_no_expiry(self):
|
||||
"""All shop items are eternal stock — the tooltip's expiry slot
|
||||
always reads 'no expiry' (matches PASS/BAND/CARTE token tooltips,
|
||||
DRY-reuses `.tt-expiry` SCSS for the red callout)."""
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe", name="Probe", price_cents=100,
|
||||
granted_token_type=Token.TITHE, granted_count=1,
|
||||
)
|
||||
self.assertEqual(item.tooltip_expiry(), "no expiry")
|
||||
|
||||
def test_is_available_for_unlimited_item(self):
|
||||
"""`max_owned=None` → item is always available."""
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe", name="Tithe", price_cents=100,
|
||||
granted_token_type=Token.TITHE, granted_count=1,
|
||||
)
|
||||
user = User.objects.create(email="ok@test.io")
|
||||
self.assertTrue(item.is_available_for(user))
|
||||
|
||||
def test_is_available_for_when_under_max_owned(self):
|
||||
"""`max_owned=1` + user owns 0 → available."""
|
||||
item = ShopItem.objects.create(
|
||||
slug="band", name="Wristband", price_cents=2000,
|
||||
granted_token_type=Token.BAND, granted_count=1, max_owned=1,
|
||||
)
|
||||
user = User.objects.create(email="ok@test.io")
|
||||
self.assertTrue(item.is_available_for(user))
|
||||
|
||||
def test_is_not_available_for_at_max_owned(self):
|
||||
"""`max_owned=1` + user already owns 1 BAND → not available."""
|
||||
item = ShopItem.objects.create(
|
||||
slug="band", name="Wristband", price_cents=2000,
|
||||
granted_token_type=Token.BAND, granted_count=1, max_owned=1,
|
||||
)
|
||||
user = User.objects.create(email="bandowner@test.io")
|
||||
Token.objects.create(user=user, token_type=Token.BAND)
|
||||
self.assertFalse(item.is_available_for(user))
|
||||
|
||||
|
||||
class PurchaseModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="buyer@test.io")
|
||||
# Drop the auto-FREE so the FREE-token count assertions stay tight.
|
||||
self.user.tokens.all().delete()
|
||||
self.user.refresh_from_db()
|
||||
self.tithe_item = ShopItem.objects.create(
|
||||
slug="probe-tithe", name="Tithe", price_cents=100,
|
||||
granted_token_type=Token.TITHE, granted_count=1, granted_writs=144,
|
||||
)
|
||||
|
||||
def test_create_purchase_minimal(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user,
|
||||
shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_test_123",
|
||||
amount_cents=100,
|
||||
granted_writs=144,
|
||||
)
|
||||
self.assertEqual(purchase.user, self.user)
|
||||
self.assertEqual(purchase.shop_item, self.tithe_item)
|
||||
self.assertEqual(purchase.status, Purchase.PENDING)
|
||||
self.assertEqual(purchase.granted_token_ids, [])
|
||||
self.assertIsNone(purchase.succeeded_at)
|
||||
|
||||
def test_stripe_payment_intent_id_is_unique(self):
|
||||
Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_dup", amount_cents=100, granted_writs=144,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_dup",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
|
||||
def test_fulfill_mints_tokens(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_fulfill_1",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
purchase.fulfill()
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||
)
|
||||
|
||||
def test_fulfill_grants_writs(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_fulfill_2",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
before = self.user.wallet.writs
|
||||
purchase.fulfill()
|
||||
self.user.wallet.refresh_from_db()
|
||||
self.assertEqual(self.user.wallet.writs, before + 144)
|
||||
|
||||
def test_fulfill_marks_succeeded(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_fulfill_3",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
purchase.fulfill()
|
||||
purchase.refresh_from_db()
|
||||
self.assertEqual(purchase.status, Purchase.SUCCEEDED)
|
||||
self.assertIsNotNone(purchase.succeeded_at)
|
||||
|
||||
def test_fulfill_records_granted_token_ids(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_fulfill_4",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
purchase.fulfill()
|
||||
purchase.refresh_from_db()
|
||||
tithe = self.user.tokens.filter(token_type=Token.TITHE).first()
|
||||
self.assertIn(tithe.pk, purchase.granted_token_ids)
|
||||
|
||||
def test_fulfill_is_idempotent(self):
|
||||
"""Webhook + sync-confirm race could fire `fulfill()` twice — second
|
||||
call must be a no-op (no double-mint, no double-writs)."""
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_idem",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
purchase.fulfill()
|
||||
first_writs = self.user.wallet.refresh_from_db() or self.user.wallet.writs
|
||||
purchase.fulfill()
|
||||
self.user.wallet.refresh_from_db()
|
||||
self.assertEqual(self.user.wallet.writs, first_writs)
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||
)
|
||||
|
||||
def test_fulfill_creates_multiple_tokens_for_bundle(self):
|
||||
bundle = ShopItem.objects.create(
|
||||
slug="probe-bundle", name="Tithe ×5", price_cents=400,
|
||||
granted_token_type=Token.TITHE, granted_count=5, granted_writs=750,
|
||||
)
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=bundle,
|
||||
stripe_payment_intent_id="pi_bundle",
|
||||
amount_cents=400, granted_writs=750,
|
||||
)
|
||||
purchase.fulfill()
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 5
|
||||
)
|
||||
|
||||
|
||||
class SeededShopCatalogTest(TestCase):
|
||||
"""Migration `lyric/0008_seed_shop_items` seeds the 3 starting items.
|
||||
Pinned here so a future migration that touches the table can't quietly
|
||||
drop / rename / re-price them without a deliberate update to these
|
||||
assertions."""
|
||||
|
||||
def test_tithe_one_item_present(self):
|
||||
item = ShopItem.objects.get(slug="tithe-1")
|
||||
self.assertEqual(item.price_cents, 100)
|
||||
self.assertEqual(item.granted_token_type, Token.TITHE)
|
||||
self.assertEqual(item.granted_count, 1)
|
||||
# Re-balanced 2026-05-22 (migration `0010_repricing_tithe_writs`):
|
||||
# 144 → 12 writs per tithe-1 purchase.
|
||||
self.assertEqual(item.granted_writs, 12)
|
||||
self.assertIsNone(item.max_owned)
|
||||
|
||||
def test_tithe_five_bundle_item_present(self):
|
||||
item = ShopItem.objects.get(slug="tithe-5")
|
||||
self.assertEqual(item.price_cents, 400)
|
||||
self.assertEqual(item.granted_count, 5)
|
||||
# Re-balanced 2026-05-22: 750 → 60 writs per bundle purchase.
|
||||
self.assertEqual(item.granted_writs, 60)
|
||||
self.assertEqual(item.badge_text, "×5")
|
||||
|
||||
def test_band_item_present(self):
|
||||
item = ShopItem.objects.get(slug="band-1")
|
||||
self.assertEqual(item.price_cents, 2000)
|
||||
self.assertEqual(item.granted_token_type, Token.BAND)
|
||||
self.assertEqual(item.granted_count, 1)
|
||||
self.assertEqual(item.max_owned, 1)
|
||||
self.assertEqual(item.granted_writs, 0)
|
||||
# BAND carries the italic shoptalk line from the Token tooltip —
|
||||
# DRY w. `Token.tooltip_shoptalk` for the BAND type.
|
||||
self.assertEqual(item.shoptalk, "Unlimited free entry (BYOB)")
|
||||
|
||||
def test_tithe_items_have_no_shoptalk(self):
|
||||
"""Tithes don't carry italic flavor in the Token tooltip; shop
|
||||
mirrors that — `.tt-shoptalk` slot is empty + the template's
|
||||
`{% if %}` skips the line cleanly."""
|
||||
self.assertEqual(ShopItem.objects.get(slug="tithe-1").shoptalk, "")
|
||||
self.assertEqual(ShopItem.objects.get(slug="tithe-5").shoptalk, "")
|
||||
|
||||
def test_all_three_items_active(self):
|
||||
for slug in ("tithe-1", "tithe-5", "band-1"):
|
||||
self.assertTrue(
|
||||
ShopItem.objects.get(slug=slug).active,
|
||||
msg=f"{slug} should be active",
|
||||
)
|
||||
|
||||
def test_display_order_ascends(self):
|
||||
"""Catalog display order matches the spec: tithe-1 < tithe-5 < band-1."""
|
||||
slugs = list(
|
||||
ShopItem.objects.order_by("display_order").values_list("slug", flat=True)
|
||||
)
|
||||
# filter to the seeded ones (in case future migrations add more)
|
||||
seeded = [s for s in slugs if s in ("tithe-1", "tithe-5", "band-1")]
|
||||
self.assertEqual(seeded, ["tithe-1", "tithe-5", "band-1"])
|
||||
|
||||
|
||||
class SeededWalletShopAppletTest(TestCase):
|
||||
def test_wallet_shop_applet_present(self):
|
||||
applet = Applet.objects.get(slug="wallet-shop")
|
||||
self.assertEqual(applet.context, Applet.WALLET)
|
||||
self.assertEqual(applet.grid_cols, 12)
|
||||
@@ -221,6 +221,7 @@ MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain
|
||||
# Stripe payment settings
|
||||
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
|
||||
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
|
||||
|
||||
# PySwiss ephemeris microservice
|
||||
PYSWISS_URL = os.environ.get("PYSWISS_URL", "http://127.0.0.1:8001")
|
||||
|
||||
@@ -17,6 +17,10 @@ urlpatterns = [
|
||||
path('billboard/', include('apps.billboard.urls')),
|
||||
path('ap/', include('apps.ap.urls')),
|
||||
path('.well-known/webfinger', ap_views.webfinger, name='webfinger'),
|
||||
# Stripe webhook lives at a stable root-level URL (no `dashboard/` prefix
|
||||
# so we can keep the same endpoint pinned in the Stripe dashboard's
|
||||
# webhook config across any future app-routing refactors).
|
||||
path('stripe/webhook', dash_views.stripe_webhook, name='stripe_webhook'),
|
||||
]
|
||||
|
||||
# Please remove the following urlpattern
|
||||
|
||||
@@ -3,21 +3,62 @@ from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from apps.applets.models import Applet
|
||||
from apps.lyric.models import ShopItem, Token, User
|
||||
|
||||
|
||||
class WalletDisplayTest(FunctionalTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
|
||||
for slug, name, cols, rows in [
|
||||
("wallet-balances", "Wallet Balances", 3, 3),
|
||||
("wallet-tokens", "Wallet Tokens", 3, 3),
|
||||
("wallet-payment", "Payment Methods", 6, 3),
|
||||
for slug, name, cols, rows, order in [
|
||||
("wallet-shop", "Shop", 12, 3, 10),
|
||||
("wallet-balances", "Wallet Balances", 3, 3, 100),
|
||||
("wallet-tokens", "Wallet Tokens", 3, 3, 100),
|
||||
("wallet-payment", "Payment Methods", 6, 3, 100),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
Applet.objects.update_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "wallet"},
|
||||
defaults={
|
||||
"name": name, "grid_cols": cols, "grid_rows": rows,
|
||||
"context": "wallet", "display_order": order,
|
||||
},
|
||||
)
|
||||
# Seed the 3 starting ShopItems — migration `lyric/0009_seed_shop_items`
|
||||
# populates these in fresh DBs, but FTs run on `TransactionTestCase`
|
||||
# which wipes all data between tests (post-migrate signal pollution
|
||||
# doesn't restore RunPython-seeded rows). Mirror the migration's
|
||||
# locked row shapes here.
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="tithe-1",
|
||||
defaults={
|
||||
"name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
|
||||
"icon": "fa-piggy-bank", "badge_text": "",
|
||||
"price_cents": 100, "granted_token_type": Token.TITHE,
|
||||
"granted_count": 1, "granted_writs": 12,
|
||||
"max_owned": None, "display_order": 10, "active": True,
|
||||
},
|
||||
)
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="tithe-5",
|
||||
defaults={
|
||||
"name": "Tithe Bundle", "description": "5 Tithe Tokens + 60 Writs",
|
||||
"icon": "fa-piggy-bank", "badge_text": "×5",
|
||||
"price_cents": 400, "granted_token_type": Token.TITHE,
|
||||
"granted_count": 5, "granted_writs": 60,
|
||||
"max_owned": None, "display_order": 20, "active": True,
|
||||
},
|
||||
)
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="band-1",
|
||||
defaults={
|
||||
"name": "Wristband", "description": "Admit All Entry",
|
||||
"shoptalk": "Unlimited free entry (BYOB)",
|
||||
"icon": "fa-ring", "badge_text": "",
|
||||
"price_cents": 2000, "granted_token_type": Token.BAND,
|
||||
"granted_count": 1, "granted_writs": 0,
|
||||
"max_owned": 1, "display_order": 30, "active": True,
|
||||
},
|
||||
)
|
||||
|
||||
def test_new_user_wallet_shows_starting_balances(self):
|
||||
# 1. Log in as new user
|
||||
@@ -179,20 +220,214 @@ class WalletDisplayTest(FunctionalTest):
|
||||
)
|
||||
self.assertEqual(rows, '3')
|
||||
|
||||
def test_user_can_purchase_tithe_token_bundle(self):
|
||||
# 1. Log in, navigate to wallet page
|
||||
def test_wallet_tokens_applet_shows_all_owned_trinket_types(self):
|
||||
"""Wallet Tokens applet renders every owned trinket-as-token type
|
||||
independently — PASS (staff), BAND, COIN, CARTE — alongside FREE
|
||||
+ TITHE. Pre-2026-05-22 the template had an if/elif chain that
|
||||
only showed ONE of {PASS, BAND, COIN}; that hid BAND + COIN +
|
||||
CARTE entirely from the wallet for any staff user holding a PASS,
|
||||
even though all three are usable at the gate. Chunk 1 of the
|
||||
Shop applet rollout drops that exclusivity so each trinket gets
|
||||
its own tooltipped icon in the row."""
|
||||
# 1. Build a staff user holding every trinket-as-token type
|
||||
staff = User.objects.create(email="ledger@test.io", is_staff=True)
|
||||
# post_save signal already created COIN (auto-equipped) + FREE + PASS;
|
||||
# mint the rest manually so we exercise the full inventory render.
|
||||
Token.objects.create(user=staff, token_type=Token.BAND)
|
||||
Token.objects.create(user=staff, token_type=Token.CARTE)
|
||||
Token.objects.create(user=staff, token_type=Token.TITHE)
|
||||
# 2. Log in + land on wallet page
|
||||
self.create_pre_authenticated_session("ledger@test.io")
|
||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||
# 3. Every trinket-as-token icon is present (no if/elif suppression)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pass_token"))
|
||||
self.browser.find_element(By.ID, "id_band_token")
|
||||
self.browser.find_element(By.ID, "id_coin_on_a_string")
|
||||
self.browser.find_element(By.ID, "id_carte_token")
|
||||
# 4. Consumable tokens still present
|
||||
self.browser.find_element(By.ID, "id_free_token")
|
||||
self.browser.find_element(By.ID, "id_tithe_token")
|
||||
# 5. BAND tile carries its tooltip content in the DOM (the wallet's
|
||||
# `initWalletTooltips` clones `.tt` innerHTML into the portal on
|
||||
# hover — already exercised by the COIN/FREE hover paths in
|
||||
# `test_new_user_wallet_shows_starting_balances`; here we just pin
|
||||
# that the new BAND/CARTE template blocks server-render their full
|
||||
# tooltip prose, which is the Chunk 1 contract).
|
||||
band_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_band_token .tt").get_attribute("innerHTML")
|
||||
self.assertIn("Wristband", band_tt)
|
||||
self.assertIn("Admit All Entry", band_tt)
|
||||
self.assertIn("no expiry", band_tt)
|
||||
# 6. CARTE tile carries its tooltip content too
|
||||
carte_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_carte_token .tt").get_attribute("innerHTML")
|
||||
self.assertIn("Carte Blanche", carte_tt)
|
||||
self.assertIn("Admit up to +6", carte_tt)
|
||||
self.assertIn("no expiry", carte_tt)
|
||||
|
||||
|
||||
def test_shop_applet_renders_seeded_items_with_icons_and_badges(self):
|
||||
"""Chunk 4 of [[project-wallet-shop-expansion]] — the new Shop
|
||||
applet renders the 3 seeded items (`tithe-1`, `tithe-5`, `band-1`)
|
||||
as tooltipped tiles. The bundle (`tithe-5`) carries a `×5` badge;
|
||||
the single-tithe + band tiles render without a badge.
|
||||
|
||||
Pinned visual contract: tile presence (id), icon class, badge text,
|
||||
and price text inside the tooltip prose."""
|
||||
self.create_pre_authenticated_session("capman@test.io")
|
||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||
# 2. Assert Tithe Token purchase section present
|
||||
# 1. Shop applet is present
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
|
||||
# 2. Each of the 3 seeded items renders w. its own tile
|
||||
tithe1 = self.browser.find_element(By.ID, "id_shop_tithe-1")
|
||||
tithe5 = self.browser.find_element(By.ID, "id_shop_tithe-5")
|
||||
band1 = self.browser.find_element(By.ID, "id_shop_band-1")
|
||||
# 3. Icons are correct
|
||||
self.assertIn("fa-piggy-bank", tithe1.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
|
||||
self.assertIn("fa-piggy-bank", tithe5.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
|
||||
self.assertIn("fa-ring", band1.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
|
||||
# 4. Bundle carries the ×5 badge; singles don't
|
||||
self.assertIn("×5", tithe5.find_element(By.CSS_SELECTOR, ".shop-badge").text)
|
||||
self.assertEqual(tithe1.find_elements(By.CSS_SELECTOR, ".shop-badge"), [])
|
||||
self.assertEqual(band1.find_elements(By.CSS_SELECTOR, ".shop-badge"), [])
|
||||
# 5. Tooltip prose includes name + price (read .tt innerHTML directly
|
||||
# — hover→portal cloning is already exercised by the existing
|
||||
# COIN/FREE hover tests).
|
||||
tithe1_tt = tithe1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
|
||||
self.assertIn("Tithe Token", tithe1_tt)
|
||||
self.assertIn("$1", tithe1_tt)
|
||||
band1_tt = band1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
|
||||
self.assertIn("Wristband", band1_tt)
|
||||
self.assertIn("$20", band1_tt)
|
||||
|
||||
def test_shop_buy_click_opens_guard_portal_with_purchase_prompt(self):
|
||||
"""BUY ITEM click opens `#id_guard_portal` w. an 'Are you sure?'-style
|
||||
prompt naming the item + price. Click NVM dismisses; click OK
|
||||
triggers the Stripe.js dance (mocked out / deferred to a follow-up
|
||||
FT)."""
|
||||
self.create_pre_authenticated_session("capman@test.io")
|
||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
|
||||
# 1. Find BUY ITEM btn inside the tithe-1 tile's microtooltip
|
||||
buy_btn = self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_shop_tithe-1 .tt-buy-btn"
|
||||
)
|
||||
# 2. Click it via JS (microtooltip lives in the portal layer + may
|
||||
# not be hover-visible during Selenium's strict-target check)
|
||||
self.browser.execute_script("arguments[0].click();", buy_btn)
|
||||
# 3. Guard portal opens w. a prompt mentioning Tithe + $1
|
||||
portal = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_guard_portal")
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_tithe_token_shop")
|
||||
lambda: self.assertIn("active", portal.get_attribute("class"))
|
||||
)
|
||||
# 3. Assert min. +1 bundle option is visible
|
||||
bundle = self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tithe_token_shop .token-bundle"
|
||||
msg = portal.find_element(By.CSS_SELECTOR, ".guard-message").text
|
||||
self.assertIn("Tithe", msg)
|
||||
self.assertIn("$1", msg)
|
||||
# 4. NVM dismisses
|
||||
portal.find_element(By.CSS_SELECTOR, ".guard-no").click()
|
||||
self.wait_for(
|
||||
lambda: self.assertNotIn("active", portal.get_attribute("class"))
|
||||
)
|
||||
# 4. Assert ea. bundle shows token count & writ bonus placeholder
|
||||
self.assertIn("Tithe Token", bundle.text)
|
||||
self.assertIn("Writ", bundle.text)
|
||||
# 5. (Placeholder) Purchase flow via Stripe not driven in this FT:
|
||||
# Full charge assertion deferred until Stripe webhook handling implemented
|
||||
|
||||
def test_shop_buy_guard_portal_pins_item_tooltip(self):
|
||||
"""While the BUY-ITEM guard portal is open, the item's main +
|
||||
mini tooltip stay pinned — they don't dismiss when the cursor
|
||||
leaves the BUY btn area to reach the guard's OK / NVM. Fixes
|
||||
the orphan-prompt UX where "Buy {item} for ${price}?" floated
|
||||
on its own w. no visual referent. Pinning is released on
|
||||
either confirm OR dismiss; both schedule the normal hide."""
|
||||
import time
|
||||
self.create_pre_authenticated_session("capman@test.io")
|
||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
|
||||
# 1. Programmatically trigger the tile-enter → both portals active.
|
||||
self.browser.execute_script(
|
||||
"document.getElementById('id_shop_tithe-5').dispatchEvent("
|
||||
"new MouseEvent('mouseenter', {bubbles: true}));"
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.assertIn(
|
||||
"active",
|
||||
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
|
||||
)
|
||||
)
|
||||
self.assertIn(
|
||||
"active",
|
||||
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
|
||||
)
|
||||
# 2. Click the cloned BUY btn inside the mini portal — opens guard.
|
||||
self.browser.execute_script(
|
||||
"document.querySelector('#id_mini_tooltip_portal .tt-buy-btn').click();"
|
||||
)
|
||||
guard = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_guard_portal")
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("active", guard.get_attribute("class"))
|
||||
)
|
||||
# 3. Simulate cursor leaving both portals to reach the guard btns.
|
||||
self.browser.execute_script("""
|
||||
document.getElementById('id_mini_tooltip_portal').dispatchEvent(
|
||||
new MouseEvent('mouseleave', {bubbles: true}));
|
||||
document.getElementById('id_tooltip_portal').dispatchEvent(
|
||||
new MouseEvent('mouseleave', {bubbles: true}));
|
||||
""")
|
||||
# 4. Wait longer than the wallet.js hide delay (200ms) + buffer.
|
||||
time.sleep(0.4)
|
||||
# 5. Both portals are STILL active — pinned by the open guard.
|
||||
self.assertIn(
|
||||
"active",
|
||||
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
|
||||
)
|
||||
self.assertIn(
|
||||
"active",
|
||||
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
|
||||
)
|
||||
# 6. Click NVM — guard dismisses, pin releases.
|
||||
guard.find_element(By.CSS_SELECTOR, ".guard-no").click()
|
||||
self.wait_for(
|
||||
lambda: self.assertNotIn("active", guard.get_attribute("class"))
|
||||
)
|
||||
# 7. After unpin + hide delay, portals fade out.
|
||||
time.sleep(0.4)
|
||||
self.assertNotIn(
|
||||
"active",
|
||||
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"active",
|
||||
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
|
||||
)
|
||||
|
||||
|
||||
def test_shop_band_already_owned_shows_disabled_buy_btn(self):
|
||||
"""User who already owns 1 BAND (`max_owned=1`) sees the band-1
|
||||
tile w. its BUY btn disabled + an 'Already owned' microtooltip
|
||||
— visible-but-unbuyable per the decision in [[project-wallet-
|
||||
shop-expansion]]."""
|
||||
owner = User.objects.create(email="bander@test.io")
|
||||
Token.objects.create(user=owner, token_type=Token.BAND)
|
||||
self.create_pre_authenticated_session("bander@test.io")
|
||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
|
||||
band_tile = self.browser.find_element(By.ID, "id_shop_band-1")
|
||||
# Capped item — NO buy btn at all (parity w. Game Kit's status-
|
||||
# only "Equipped" / "In-Use: X" pills, which never pair status
|
||||
# text w. a disabled action btn).
|
||||
self.assertEqual(band_tile.find_elements(By.CSS_SELECTOR, ".tt-buy-btn"), [])
|
||||
# 'Already owned' microtext lives in the `.tt-micro` sibling-of-.tt
|
||||
# (cloned into `#id_mini_tooltip_portal` on hover by wallet.js).
|
||||
micro_html = band_tile.find_element(By.CSS_SELECTOR, ".tt-micro").get_attribute("innerHTML")
|
||||
self.assertIn("Already owned", micro_html)
|
||||
|
||||
|
||||
# Legacy `test_user_can_purchase_tithe_token_bundle` FT (asserting
|
||||
# `#id_tithe_token_shop` inside Balances) was removed in Chunk 5 of
|
||||
# [[project-wallet-shop-expansion]] — the tithe purchase surface
|
||||
# moved to the dedicated Shop applet. Coverage now lives in:
|
||||
# - `test_shop_applet_renders_seeded_items_with_icons_and_badges`
|
||||
# (tile + icon + badge + price)
|
||||
# - `test_shop_buy_click_opens_guard_portal_with_purchase_prompt`
|
||||
# (BUY → guard portal → NVM dismisses)
|
||||
# - `test_shop_band_already_owned_shows_disabled_buy_btn`
|
||||
# (max_owned cap renders BUY as `.btn-disabled` w. microtext)
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
<script src="NoteSpec.js"></script>
|
||||
<script src="NotePageSpec.js"></script>
|
||||
<script src="RowLockSpec.js"></script>
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/dashboard/note.js"></script>
|
||||
<script src="/static/apps/dashboard/wallet-shop.js"></script>
|
||||
<script src="/static/apps/billboard/note-page.js"></script>
|
||||
<script src="/static/apps/epic/stage-card.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
|
||||
203
src/static/tests/WalletShopSpec.js
Normal file
203
src/static/tests/WalletShopSpec.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// ── WalletShopSpec.js ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for wallet-shop.js — the BUY-ITEM click handler that drives
|
||||
// the shop applet's purchase flow (Chunk 4 of the wallet-expansion sprint).
|
||||
//
|
||||
// DOM contract assumed by `initWalletShop()`:
|
||||
//
|
||||
// Each shop tile carries `data-shop-item-slug`, `data-price-cents`, and
|
||||
// `data-item-name`. Inside the tile, a `.tt-buy-btn` element is the
|
||||
// click target. Disabled tiles have `.btn-disabled` on the BUY btn +
|
||||
// should NOT trigger the buy flow.
|
||||
//
|
||||
// `window.showGuard(anchor, message, onConfirm)` is the project's
|
||||
// guard-portal API (from base.html). We spy on it to assert message
|
||||
// + callback wiring.
|
||||
//
|
||||
// `window.Stripe` and `window.fetch` are stubbed out per-test so we
|
||||
// never hit the real Stripe.js or backend.
|
||||
//
|
||||
// API under test:
|
||||
// `initWalletShop()` — attaches click listeners to every `.tt-buy-btn`
|
||||
// inside `.wallet-shop`. Idempotent (calling twice
|
||||
// doesn't double-fire).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _seedShopFixture() {
|
||||
const root = document.createElement('section');
|
||||
root.className = 'wallet-shop';
|
||||
root.id = 'id_wallet_shop';
|
||||
// `_doBuy` reads these from `.wallet-shop` dataset; without them the
|
||||
// buy flow short-circuits at the no-PM alert before reaching fetch.
|
||||
root.dataset.defaultPaymentMethodId = 'pm_test_4242';
|
||||
root.dataset.stripePublishableKey = 'pk_test_fixture';
|
||||
root.innerHTML = `
|
||||
<div id="id_shop_tithe-1" class="shop-tile">
|
||||
<i class="fa-solid fa-piggy-bank"></i>
|
||||
<div class="tt">
|
||||
<h4 class="tt-title">Tithe Token</h4>
|
||||
<p class="tt-description">1 Tithe + 144 Writs</p>
|
||||
<p class="tt-price">$1</p>
|
||||
</div>
|
||||
<div class="tt-micro">
|
||||
<button class="btn btn-primary tt-buy-btn"
|
||||
data-shop-item-slug="tithe-1"
|
||||
data-item-name="Tithe Token"
|
||||
data-price-cents="100">BUY ITEM</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="id_shop_band-1" class="shop-tile">
|
||||
<i class="fa-solid fa-ring"></i>
|
||||
<div class="tt">
|
||||
<h4 class="tt-title">Wristband</h4>
|
||||
<p class="tt-price">$20</p>
|
||||
</div>
|
||||
<div class="tt-micro">
|
||||
<span class="tt-already-owned">Already owned</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
|
||||
describe('WalletShop.initWalletShop', () => {
|
||||
let fixture;
|
||||
let _origFetch;
|
||||
let _origStripe;
|
||||
let _origShowGuard;
|
||||
let _origWalletTooltips;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = _seedShopFixture();
|
||||
// Save+stub instead of spyOn — another spec elsewhere may already
|
||||
// have a fetch spy active, and Jasmine refuses to double-spy on
|
||||
// the same method ("fetch has already been spied upon"). Manual
|
||||
// save+restore is double-spy-safe + scoped per spec block.
|
||||
_origFetch = window.fetch;
|
||||
window.fetch = jasmine.createSpy('fetch').and.returnValue(
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
|
||||
);
|
||||
_origStripe = window.Stripe;
|
||||
window.Stripe = jasmine.createSpy('Stripe').and.returnValue({
|
||||
confirmCardPayment: jasmine.createSpy('confirmCardPayment')
|
||||
.and.returnValue(Promise.resolve({ paymentIntent: { status: 'succeeded' } })),
|
||||
});
|
||||
_origShowGuard = window.showGuard;
|
||||
window.showGuard = jasmine.createSpy('showGuard');
|
||||
// Stub WalletTooltips.pin/unpin so we can assert the buy flow
|
||||
// pins the tooltip on guard-open + unpins on confirm/dismiss.
|
||||
_origWalletTooltips = window.WalletTooltips;
|
||||
window.WalletTooltips = {
|
||||
pin: jasmine.createSpy('pin'),
|
||||
unpin: jasmine.createSpy('unpin'),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.remove();
|
||||
window.fetch = _origFetch;
|
||||
window.Stripe = _origStripe;
|
||||
window.showGuard = _origShowGuard;
|
||||
window.WalletTooltips = _origWalletTooltips;
|
||||
});
|
||||
|
||||
// ── T1 ── click on enabled BUY opens guard portal w. price prompt ───────
|
||||
it('T1: clicking BUY ITEM on tithe-1 opens guard with the item name + price', () => {
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
expect(window.showGuard).toHaveBeenCalled();
|
||||
const args = window.showGuard.calls.mostRecent().args;
|
||||
expect(args[0]).toBe(btn); // anchor
|
||||
expect(args[1]).toContain('Tithe Token');
|
||||
expect(args[1]).toContain('$1');
|
||||
expect(typeof args[2]).toBe('function'); // onConfirm callback
|
||||
});
|
||||
|
||||
// ── T2 ── capped items render no BUY btn at all (status-only microtext)
|
||||
it('T2: capped band-1 tile renders no BUY btn (only the "Already owned" pill)', () => {
|
||||
WalletShop.initWalletShop();
|
||||
// No `.tt-buy-btn` inside the band tile — capped items follow the
|
||||
// Game Kit "Equipped" / "In-Use: X" pattern: status text only, no
|
||||
// disabled action btn. Clicking anywhere on the tile is a no-op.
|
||||
const btns = fixture.querySelectorAll('#id_shop_band-1 .tt-buy-btn');
|
||||
expect(btns.length).toBe(0);
|
||||
// The microtext is rendered (cloned into the mini portal on hover).
|
||||
const ownedText = fixture.querySelector('#id_shop_band-1 .tt-already-owned');
|
||||
expect(ownedText).not.toBeNull();
|
||||
expect(ownedText.textContent.trim()).toBe('Already owned');
|
||||
});
|
||||
|
||||
// ── T3 ── onConfirm callback POSTs to /shop/buy w. the right slug ───────
|
||||
it('T3: invoking the onConfirm callback POSTs shop_item_slug to /shop/buy', () => {
|
||||
// T3-local fetch override: never-resolving promise so the async
|
||||
// chain pauses at the FIRST await + doesn't progress into the
|
||||
// Stripe / confirm-endpoint dance that gets torn down by
|
||||
// afterEach. Without this, the unawaited continuation fires
|
||||
// `window.Stripe(pubKey)` after afterEach has restored
|
||||
// window.Stripe = undefined → unhandled-promise-rejection error.
|
||||
window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {}));
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
const onConfirm = window.showGuard.calls.mostRecent().args[2];
|
||||
onConfirm();
|
||||
expect(window.fetch).toHaveBeenCalled();
|
||||
const [url, opts] = window.fetch.calls.mostRecent().args;
|
||||
expect(url).toContain('/dashboard/wallet/shop/buy');
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.body).toContain('shop_item_slug=tithe-1');
|
||||
});
|
||||
|
||||
// ── T4 ── initWalletShop is idempotent (calling twice doesn't double-wire) ─
|
||||
it('T4: calling initWalletShop twice does not double-fire the guard portal', () => {
|
||||
WalletShop.initWalletShop();
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
expect(window.showGuard.calls.count()).toBe(1);
|
||||
});
|
||||
|
||||
// ── T5 ── no `.wallet-shop` in DOM → init is a quiet no-op ──────────────
|
||||
it('T5: initWalletShop with no `.wallet-shop` root does not throw', () => {
|
||||
fixture.remove();
|
||||
expect(() => WalletShop.initWalletShop()).not.toThrow();
|
||||
});
|
||||
|
||||
// ── T6 ── clicking BUY pins the tooltip BEFORE opening the guard ──────
|
||||
it('T6: clicking BUY ITEM pins the WalletTooltips (so the item ' +
|
||||
'tooltip stays visible while the guard portal is open)', () => {
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
// pin() called BEFORE showGuard so the tooltip never has a
|
||||
// chance to hide between the click + the guard render.
|
||||
expect(window.WalletTooltips.pin).toHaveBeenCalled();
|
||||
expect(window.showGuard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── T7 ── guard onConfirm unpins the tooltip ───────────────────────────
|
||||
it('T7: invoking the onConfirm callback unpins the WalletTooltips', () => {
|
||||
// Block fetch like in T3 to avoid Stripe-chain async cleanup pollution.
|
||||
window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {}));
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
const onConfirm = window.showGuard.calls.mostRecent().args[2];
|
||||
onConfirm();
|
||||
expect(window.WalletTooltips.unpin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── T8 ── guard onDismiss unpins the tooltip ───────────────────────────
|
||||
it('T8: invoking the onDismiss callback unpins the WalletTooltips', () => {
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
const onDismiss = window.showGuard.calls.mostRecent().args[3];
|
||||
expect(typeof onDismiss).toBe('function');
|
||||
onDismiss();
|
||||
expect(window.WalletTooltips.unpin).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@
|
||||
.tt-shoptalk { font-size: 0.75rem; opacity: 0.75; }
|
||||
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
|
||||
.tt-date { font-size: 1rem; color: rgba(var(--priGn), 1); }
|
||||
// `.tt-price` — wallet Shop tooltip. Same shape as .tt-expiry (size +
|
||||
// semantics) but --priGn for the "in the green" payment cue. Lives
|
||||
// inside the `.tt-title` h4 (which is `display: flex; justify-content:
|
||||
// space-between`) so the price floats top-right opposite the name.
|
||||
.tt-price { font-size: 1rem; color: rgba(var(--priGn), 1); }
|
||||
}
|
||||
|
||||
.token-tooltip,
|
||||
|
||||
@@ -71,3 +71,106 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Wallet Shop applet ───────────────────────────────────────────────────────
|
||||
// Mimics `.wallet-tokens` (horizontal row of tooltipped icons) but each tile
|
||||
// carries an admin-defined catalog item + an optional `.shop-badge` (eg "×5"
|
||||
// for the bundle) + a BUY-ITEM microbutton hosted in the tooltip portal.
|
||||
// JS wiring lives in `apps/dashboard/static/apps/dashboard/wallet-shop.js`.
|
||||
|
||||
.wallet-shop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
|
||||
.shop-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
gap: 1rem;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.shop-tile {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: rgba(var(--terUser), 1);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
// ×N quantity badge — top-right corner, --quaUser glyph on --quiUser bg.
|
||||
// User-tweaked 2026-05-22: shrunk from 2rem → 1.5rem + nudged further up
|
||||
// + right so most of the underlying tile icon stays visible.
|
||||
.shop-badge {
|
||||
position: absolute;
|
||||
top: -0.8rem;
|
||||
right: -1.2rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--secUser), 1);
|
||||
color: rgba(var(--priUser), 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// `.tt-micro` — sibling of `.tt` on each `.shop-tile`. Holds the BUY-ITEM
|
||||
// btn (or × + 'Already owned' when capped). wallet.js's tooltip handler
|
||||
// clones this into `#id_mini_tooltip_portal` on hover so the btn appears
|
||||
// as a small floating bubble adjacent to the main tooltip card —
|
||||
// mirroring Game Kit's Equipped/Unequipped/In-Use microtooltip pattern.
|
||||
// Hidden in the source DOM; only the portal clone is visible.
|
||||
.tt-micro {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Wallet-side mini portal — pinned to the bottom-right of the main
|
||||
// portal by wallet.js (mirrors gameboard.js's gameKit positioning).
|
||||
// Mostly mirrors gameboard's mini at `_gameboard.scss:140` but allows
|
||||
// the BUY-ITEM btn label to wrap onto multiple lines (gameboard's
|
||||
// mini holds short status text like "In-Use: X" which wants nowrap;
|
||||
// our buy btn is round + needs the label to break onto 2 lines).
|
||||
#id_mini_tooltip_portal {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: fit-content;
|
||||
text-align: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
.tt-buy-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
// `.tt-already-owned` text — match Game Kit's "Equipped" / "In-Use: X"
|
||||
// microtext styling (--secUser at full alpha, slightly bigger than
|
||||
// 0.75rem) so the wallet shop's "Already owned" pill reads as the
|
||||
// same widget as the gameboard's status pills.
|
||||
.tt-already-owned {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
color: rgba(var(--secUser), 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.active { display: flex; }
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
<script src="NoteSpec.js"></script>
|
||||
<script src="NotePageSpec.js"></script>
|
||||
<script src="RowLockSpec.js"></script>
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/dashboard/note.js"></script>
|
||||
<script src="/static/apps/dashboard/wallet-shop.js"></script>
|
||||
<script src="/static/apps/billboard/note-page.js"></script>
|
||||
<script src="/static/apps/epic/stage-card.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
|
||||
203
src/static_src/tests/WalletShopSpec.js
Normal file
203
src/static_src/tests/WalletShopSpec.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// ── WalletShopSpec.js ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for wallet-shop.js — the BUY-ITEM click handler that drives
|
||||
// the shop applet's purchase flow (Chunk 4 of the wallet-expansion sprint).
|
||||
//
|
||||
// DOM contract assumed by `initWalletShop()`:
|
||||
//
|
||||
// Each shop tile carries `data-shop-item-slug`, `data-price-cents`, and
|
||||
// `data-item-name`. Inside the tile, a `.tt-buy-btn` element is the
|
||||
// click target. Disabled tiles have `.btn-disabled` on the BUY btn +
|
||||
// should NOT trigger the buy flow.
|
||||
//
|
||||
// `window.showGuard(anchor, message, onConfirm)` is the project's
|
||||
// guard-portal API (from base.html). We spy on it to assert message
|
||||
// + callback wiring.
|
||||
//
|
||||
// `window.Stripe` and `window.fetch` are stubbed out per-test so we
|
||||
// never hit the real Stripe.js or backend.
|
||||
//
|
||||
// API under test:
|
||||
// `initWalletShop()` — attaches click listeners to every `.tt-buy-btn`
|
||||
// inside `.wallet-shop`. Idempotent (calling twice
|
||||
// doesn't double-fire).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _seedShopFixture() {
|
||||
const root = document.createElement('section');
|
||||
root.className = 'wallet-shop';
|
||||
root.id = 'id_wallet_shop';
|
||||
// `_doBuy` reads these from `.wallet-shop` dataset; without them the
|
||||
// buy flow short-circuits at the no-PM alert before reaching fetch.
|
||||
root.dataset.defaultPaymentMethodId = 'pm_test_4242';
|
||||
root.dataset.stripePublishableKey = 'pk_test_fixture';
|
||||
root.innerHTML = `
|
||||
<div id="id_shop_tithe-1" class="shop-tile">
|
||||
<i class="fa-solid fa-piggy-bank"></i>
|
||||
<div class="tt">
|
||||
<h4 class="tt-title">Tithe Token</h4>
|
||||
<p class="tt-description">1 Tithe + 144 Writs</p>
|
||||
<p class="tt-price">$1</p>
|
||||
</div>
|
||||
<div class="tt-micro">
|
||||
<button class="btn btn-primary tt-buy-btn"
|
||||
data-shop-item-slug="tithe-1"
|
||||
data-item-name="Tithe Token"
|
||||
data-price-cents="100">BUY ITEM</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="id_shop_band-1" class="shop-tile">
|
||||
<i class="fa-solid fa-ring"></i>
|
||||
<div class="tt">
|
||||
<h4 class="tt-title">Wristband</h4>
|
||||
<p class="tt-price">$20</p>
|
||||
</div>
|
||||
<div class="tt-micro">
|
||||
<span class="tt-already-owned">Already owned</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
|
||||
describe('WalletShop.initWalletShop', () => {
|
||||
let fixture;
|
||||
let _origFetch;
|
||||
let _origStripe;
|
||||
let _origShowGuard;
|
||||
let _origWalletTooltips;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = _seedShopFixture();
|
||||
// Save+stub instead of spyOn — another spec elsewhere may already
|
||||
// have a fetch spy active, and Jasmine refuses to double-spy on
|
||||
// the same method ("fetch has already been spied upon"). Manual
|
||||
// save+restore is double-spy-safe + scoped per spec block.
|
||||
_origFetch = window.fetch;
|
||||
window.fetch = jasmine.createSpy('fetch').and.returnValue(
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
|
||||
);
|
||||
_origStripe = window.Stripe;
|
||||
window.Stripe = jasmine.createSpy('Stripe').and.returnValue({
|
||||
confirmCardPayment: jasmine.createSpy('confirmCardPayment')
|
||||
.and.returnValue(Promise.resolve({ paymentIntent: { status: 'succeeded' } })),
|
||||
});
|
||||
_origShowGuard = window.showGuard;
|
||||
window.showGuard = jasmine.createSpy('showGuard');
|
||||
// Stub WalletTooltips.pin/unpin so we can assert the buy flow
|
||||
// pins the tooltip on guard-open + unpins on confirm/dismiss.
|
||||
_origWalletTooltips = window.WalletTooltips;
|
||||
window.WalletTooltips = {
|
||||
pin: jasmine.createSpy('pin'),
|
||||
unpin: jasmine.createSpy('unpin'),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.remove();
|
||||
window.fetch = _origFetch;
|
||||
window.Stripe = _origStripe;
|
||||
window.showGuard = _origShowGuard;
|
||||
window.WalletTooltips = _origWalletTooltips;
|
||||
});
|
||||
|
||||
// ── T1 ── click on enabled BUY opens guard portal w. price prompt ───────
|
||||
it('T1: clicking BUY ITEM on tithe-1 opens guard with the item name + price', () => {
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
expect(window.showGuard).toHaveBeenCalled();
|
||||
const args = window.showGuard.calls.mostRecent().args;
|
||||
expect(args[0]).toBe(btn); // anchor
|
||||
expect(args[1]).toContain('Tithe Token');
|
||||
expect(args[1]).toContain('$1');
|
||||
expect(typeof args[2]).toBe('function'); // onConfirm callback
|
||||
});
|
||||
|
||||
// ── T2 ── capped items render no BUY btn at all (status-only microtext)
|
||||
it('T2: capped band-1 tile renders no BUY btn (only the "Already owned" pill)', () => {
|
||||
WalletShop.initWalletShop();
|
||||
// No `.tt-buy-btn` inside the band tile — capped items follow the
|
||||
// Game Kit "Equipped" / "In-Use: X" pattern: status text only, no
|
||||
// disabled action btn. Clicking anywhere on the tile is a no-op.
|
||||
const btns = fixture.querySelectorAll('#id_shop_band-1 .tt-buy-btn');
|
||||
expect(btns.length).toBe(0);
|
||||
// The microtext is rendered (cloned into the mini portal on hover).
|
||||
const ownedText = fixture.querySelector('#id_shop_band-1 .tt-already-owned');
|
||||
expect(ownedText).not.toBeNull();
|
||||
expect(ownedText.textContent.trim()).toBe('Already owned');
|
||||
});
|
||||
|
||||
// ── T3 ── onConfirm callback POSTs to /shop/buy w. the right slug ───────
|
||||
it('T3: invoking the onConfirm callback POSTs shop_item_slug to /shop/buy', () => {
|
||||
// T3-local fetch override: never-resolving promise so the async
|
||||
// chain pauses at the FIRST await + doesn't progress into the
|
||||
// Stripe / confirm-endpoint dance that gets torn down by
|
||||
// afterEach. Without this, the unawaited continuation fires
|
||||
// `window.Stripe(pubKey)` after afterEach has restored
|
||||
// window.Stripe = undefined → unhandled-promise-rejection error.
|
||||
window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {}));
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
const onConfirm = window.showGuard.calls.mostRecent().args[2];
|
||||
onConfirm();
|
||||
expect(window.fetch).toHaveBeenCalled();
|
||||
const [url, opts] = window.fetch.calls.mostRecent().args;
|
||||
expect(url).toContain('/dashboard/wallet/shop/buy');
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.body).toContain('shop_item_slug=tithe-1');
|
||||
});
|
||||
|
||||
// ── T4 ── initWalletShop is idempotent (calling twice doesn't double-wire) ─
|
||||
it('T4: calling initWalletShop twice does not double-fire the guard portal', () => {
|
||||
WalletShop.initWalletShop();
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
expect(window.showGuard.calls.count()).toBe(1);
|
||||
});
|
||||
|
||||
// ── T5 ── no `.wallet-shop` in DOM → init is a quiet no-op ──────────────
|
||||
it('T5: initWalletShop with no `.wallet-shop` root does not throw', () => {
|
||||
fixture.remove();
|
||||
expect(() => WalletShop.initWalletShop()).not.toThrow();
|
||||
});
|
||||
|
||||
// ── T6 ── clicking BUY pins the tooltip BEFORE opening the guard ──────
|
||||
it('T6: clicking BUY ITEM pins the WalletTooltips (so the item ' +
|
||||
'tooltip stays visible while the guard portal is open)', () => {
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
// pin() called BEFORE showGuard so the tooltip never has a
|
||||
// chance to hide between the click + the guard render.
|
||||
expect(window.WalletTooltips.pin).toHaveBeenCalled();
|
||||
expect(window.showGuard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── T7 ── guard onConfirm unpins the tooltip ───────────────────────────
|
||||
it('T7: invoking the onConfirm callback unpins the WalletTooltips', () => {
|
||||
// Block fetch like in T3 to avoid Stripe-chain async cleanup pollution.
|
||||
window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {}));
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
const onConfirm = window.showGuard.calls.mostRecent().args[2];
|
||||
onConfirm();
|
||||
expect(window.WalletTooltips.unpin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── T8 ── guard onDismiss unpins the tooltip ───────────────────────────
|
||||
it('T8: invoking the onDismiss callback unpins the WalletTooltips', () => {
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
const onDismiss = window.showGuard.calls.mostRecent().args[3];
|
||||
expect(typeof onDismiss).toBe('function');
|
||||
onDismiss();
|
||||
expect(window.WalletTooltips.unpin).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,10 @@
|
||||
{% include "apps/wallet/_partials/_applets.html" %}
|
||||
</div>
|
||||
<div id="id_tooltip_portal" class="token-tooltip"></div>
|
||||
{# Microtooltip for the Shop applet's BUY-ITEM btn / 'Already owned' note. #}
|
||||
{# Mirrors gameboard.html's mini portal (Equipped/Unequipped/In-Use). #}
|
||||
<div id="id_mini_tooltip_portal" class="token-tooltip token-tooltip--mini"></div>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script src="{% static "apps/dashboard/wallet.js" %}"></script>
|
||||
<script src="{% static "apps/dashboard/wallet-shop.js" %}"></script>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -2,19 +2,13 @@
|
||||
id="id_wallet_balances"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
{% comment %}
|
||||
Tithe purchase UI was moved out to the dedicated Shop applet in
|
||||
Chunk 5 of [[project-wallet-shop-expansion]] — the Shop is the
|
||||
canonical purchase surface; Balances is read-only (writs + esteem
|
||||
totals).
|
||||
{% endcomment %}
|
||||
<h2>Balances</h2>
|
||||
<div><i class="fa-solid fa-ticket"></i>: <span id="id_writs_balance">{{ wallet.writs }}</span></div>
|
||||
<div>Esteem: <span id="id_esteem_balance">{{ wallet.esteem }}</span></div>
|
||||
<div id="id_tithe_token_shop">
|
||||
<div class="token-bundle" data-qty="1" data-price-cents="100">
|
||||
<span class="bundle-qty">1 Tithe Token</span>
|
||||
<span class="bundle-writs">+144 Writs</span>
|
||||
<span class="bundle-price">$1.00</span>
|
||||
</div>
|
||||
<div class="token-bundle" data-qty="5" data-price-cents="400">
|
||||
<span class="bundle-qty">5 Tithe Tokens</span>
|
||||
<span class="bundle-writs">+750 Writs</span>
|
||||
<span class="bundle-price">$4.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
65
src/templates/apps/wallet/_partials/_applet-wallet-shop.html
Normal file
65
src/templates/apps/wallet/_partials/_applet-wallet-shop.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<section
|
||||
id="id_wallet_shop"
|
||||
class="wallet-shop"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
data-default-payment-method-id="{{ default_payment_method_id }}"
|
||||
data-stripe-publishable-key="{{ stripe_publishable_key }}"
|
||||
>
|
||||
<h2>Shop</h2>
|
||||
<div class="shop-grid">
|
||||
{% for item in shop_items %}
|
||||
<div
|
||||
id="id_shop_{{ item.slug }}"
|
||||
class="shop-tile"
|
||||
data-shop-item-slug="{{ item.slug }}"
|
||||
data-price-cents="{{ item.price_cents }}"
|
||||
data-item-name="{{ item.name }}"
|
||||
>
|
||||
<i class="fa-solid {{ item.icon }}"></i>
|
||||
{% if item.badge_text %}
|
||||
<span class="shop-badge">{{ item.badge_text }}</span>
|
||||
{% endif %}
|
||||
<div class="tt">
|
||||
{# DRY-reuses the Tokens row's 4-slot tooltip pattern — same #}
|
||||
{# `.tt-title/.tt-description/.tt-shoptalk/.tt-expiry` SCSS #}
|
||||
{# classes so shop + token tooltips render as the same widget.#}
|
||||
{# Price lives inside the h4 (which already has flex space- #}
|
||||
{# between for `.token-count`-style chips) so it pins top- #}
|
||||
{# right opposite the name — `.tt-price` styled --priGn. #}
|
||||
<h4 class="tt-title">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="tt-price">{{ item.price_display }}</span>
|
||||
</h4>
|
||||
<p class="tt-description">{{ item.description }}</p>
|
||||
{% if item.shoptalk %}
|
||||
<p class="tt-shoptalk"><em>{{ item.shoptalk }}</em></p>
|
||||
{% endif %}
|
||||
<p class="tt-expiry">{{ item.tooltip_expiry }}</p>
|
||||
</div>
|
||||
{% comment %}
|
||||
Sibling-of-.tt microtooltip — mirrors Game Kit's
|
||||
Equipped/Unequipped/In-Use mini portal pattern. wallet.js's
|
||||
tooltip handler clones this into #id_mini_tooltip_portal on
|
||||
hover; staying separate keeps the BUY-ITEM btn visually
|
||||
distinct from the main name+price card.
|
||||
{% endcomment %}
|
||||
<div class="tt-micro">
|
||||
{% if item.available %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary tt-buy-btn"
|
||||
data-shop-item-slug="{{ item.slug }}"
|
||||
data-item-name="{{ item.name }}"
|
||||
data-price-cents="{{ item.price_cents }}"
|
||||
>BUY ITEM</button>
|
||||
{% else %}
|
||||
{# Capped item — no BUY btn at all (parity w. Game Kit's #}
|
||||
{# 'In-Use: X' / 'Equipped' microtext pattern, which never #}
|
||||
{# pairs status text w. a disabled action btn). #}
|
||||
<span class="tt-already-owned">Already owned</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -4,6 +4,12 @@
|
||||
>
|
||||
<h2>Tokens</h2>
|
||||
<div class="token-row">
|
||||
{% comment %}
|
||||
Trinket-as-token row — each trinket type renders independently (no
|
||||
if/elif suppression). Every one of PASS/BAND/COIN/CARTE is usable at
|
||||
the gate; the user picks WHICH via the equip slot per
|
||||
[[feedback-equip-slot-gates-trinket-use]].
|
||||
{% endcomment %}
|
||||
{% if pass_token %}
|
||||
<div id="id_pass_token" class="token">
|
||||
<i class="fa-solid fa-clipboard"></i>
|
||||
@@ -16,7 +22,8 @@
|
||||
<p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif band %}
|
||||
{% endif %}
|
||||
{% if band %}
|
||||
<div id="id_band_token" class="token">
|
||||
<i class="fa-solid fa-ring"></i>
|
||||
<div class="tt">
|
||||
@@ -28,7 +35,8 @@
|
||||
<p class="tt-expiry">{{ band.tooltip_expiry }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif coin %}
|
||||
{% endif %}
|
||||
{% if coin %}
|
||||
<div id="id_coin_on_a_string" class="token">
|
||||
<i class="fa-solid fa-medal"></i>
|
||||
<div class="tt">
|
||||
@@ -41,6 +49,19 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if carte %}
|
||||
<div id="id_carte_token" class="token">
|
||||
<i class="fa-solid fa-money-check"></i>
|
||||
<div class="tt">
|
||||
<h4 class="tt-title">{{ carte.tooltip_name }}</h4>
|
||||
<p class="tt-description">{{ carte.tooltip_description }}</p>
|
||||
{% if carte.tooltip_shoptalk %}
|
||||
<p class="tt-shoptalk"><em>{{ carte.tooltip_shoptalk }}</em></p>
|
||||
{% endif %}
|
||||
<p class="tt-expiry">{{ carte.tooltip_expiry }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if free_tokens %}
|
||||
{% with free_tokens.0 as token %}
|
||||
<div id="id_free_token" class="token">
|
||||
|
||||
Reference in New Issue
Block a user