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)
|
default_visible = models.BooleanField(default=True)
|
||||||
grid_cols = models.PositiveSmallIntegerField(default=12)
|
grid_cols = models.PositiveSmallIntegerField(default=12)
|
||||||
grid_rows = models.PositiveSmallIntegerField(default=3)
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ def apply_applet_toggle(user, context, checked_slugs):
|
|||||||
|
|
||||||
def applet_context(user, context):
|
def applet_context(user, context):
|
||||||
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
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 [
|
return [
|
||||||
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
|
{"applet": a, "visible": ua_map.get(a.pk, a.default_visible)}
|
||||||
for slug in applets
|
for a in applets_qs
|
||||||
if slug in applets
|
|
||||||
]
|
]
|
||||||
|
|||||||
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() {
|
function initWalletTooltips() {
|
||||||
const portal = document.getElementById('id_tooltip_portal');
|
const portal = document.getElementById('id_tooltip_portal');
|
||||||
if (!portal) return;
|
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 => {
|
// Hover-persistence — keep the portal(s) open while the cursor moves
|
||||||
const tooltip = token.querySelector('.tt');
|
// 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;
|
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', () => {
|
document.querySelectorAll('.wallet-tokens .token, .wallet-shop .shop-tile')
|
||||||
const rect = token.getBoundingClientRect();
|
.forEach(_bindHover);
|
||||||
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))';
|
|
||||||
});
|
|
||||||
|
|
||||||
token.addEventListener('mouseleave', () => {
|
// Re-entering either portal cancels the pending hide — keeps the
|
||||||
portal.classList.remove('active');
|
// 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', 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):
|
def test_wallet_page_shows_stripe_payment_element(self):
|
||||||
[_] = self.parsed.cssselect("#id_stripe_payment_element")
|
[_] = self.parsed.cssselect("#id_stripe_payment_element")
|
||||||
|
|
||||||
def test_wallet_page_shows_tithe_token_shop(self):
|
# Note: the legacy `#id_tithe_token_shop` HTML in Balances was
|
||||||
[_] = self.parsed.cssselect("#id_tithe_token_shop")
|
# 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")
|
class WalletTokensAppletAllTrinketsVisibleTest(TestCase):
|
||||||
self.assertGreater(len(bundles), 0)
|
"""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):
|
class WalletPassTokenVisibilityTest(TestCase):
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ urlpatterns = [
|
|||||||
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
|
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
|
||||||
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
|
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/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('kit-bag/', views.kit_bag, name='kit_bag'),
|
||||||
path('sky/', views.sky_view, name='sky'),
|
path('sky/', views.sky_view, name='sky'),
|
||||||
path('sky/preview', views.sky_preview, name='sky_preview'),
|
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.shortcuts import redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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.applets.utils import applet_context, apply_applet_toggle
|
||||||
from apps.drama.models import Note
|
from apps.drama.models import Note
|
||||||
from apps.epic.utils import _compute_distinctions
|
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"]
|
APPLET_ORDER = ["wallet", "username", "palette"]
|
||||||
@@ -155,6 +155,18 @@ def toggle_applets(request):
|
|||||||
})
|
})
|
||||||
return redirect("home")
|
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="/")
|
@login_required(login_url="/")
|
||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
def wallet(request):
|
def wallet(request):
|
||||||
@@ -162,11 +174,17 @@ def wallet(request):
|
|||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
).order_by("expires_at"))
|
).order_by("expires_at"))
|
||||||
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
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", {
|
return render(request, "apps/dashboard/wallet.html", {
|
||||||
"wallet": 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,
|
"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(),
|
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
|
||||||
"coin": request.user.tokens.filter(token_type=Token.COIN).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,
|
"free_tokens": free_tokens,
|
||||||
"tithe_tokens": tithe_tokens,
|
"tithe_tokens": tithe_tokens,
|
||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
@@ -198,12 +216,17 @@ def toggle_wallet_applets(request):
|
|||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
apply_applet_toggle(request.user, "wallet", checked)
|
apply_applet_toggle(request.user, "wallet", checked)
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
|
default_pm = request.user.payment_methods.order_by("-pk").first()
|
||||||
return render(request, "apps/wallet/_partials/_applets.html", {
|
return render(request, "apps/wallet/_partials/_applets.html", {
|
||||||
"applets": applet_context(request.user, "wallet"),
|
"applets": applet_context(request.user, "wallet"),
|
||||||
"wallet": 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,
|
"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(),
|
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
|
||||||
"coin": request.user.tokens.filter(token_type=Token.COIN).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)),
|
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
||||||
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
"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})
|
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) ────────────────────────────────────────────
|
# ── My Sky (personal natal chart) ────────────────────────────────────────────
|
||||||
|
|
||||||
def _sky_preview_data(request):
|
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):
|
def __str__(self):
|
||||||
return f"{self.brand} ....{self.last4}"
|
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)
|
@receiver(post_save, sender=User)
|
||||||
def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
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 payment settings
|
||||||
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
|
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
|
||||||
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
|
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
|
||||||
|
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
# PySwiss ephemeris microservice
|
# PySwiss ephemeris microservice
|
||||||
PYSWISS_URL = os.environ.get("PYSWISS_URL", "http://127.0.0.1:8001")
|
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('billboard/', include('apps.billboard.urls')),
|
||||||
path('ap/', include('apps.ap.urls')),
|
path('ap/', include('apps.ap.urls')),
|
||||||
path('.well-known/webfinger', ap_views.webfinger, name='webfinger'),
|
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
|
# Please remove the following urlpattern
|
||||||
|
|||||||
@@ -3,21 +3,62 @@ from selenium.webdriver.common.by import By
|
|||||||
|
|
||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
from apps.applets.models import Applet
|
from apps.applets.models import Applet
|
||||||
|
from apps.lyric.models import ShopItem, Token, User
|
||||||
|
|
||||||
|
|
||||||
class WalletDisplayTest(FunctionalTest):
|
class WalletDisplayTest(FunctionalTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
|
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows, order in [
|
||||||
("wallet-balances", "Wallet Balances", 3, 3),
|
("wallet-shop", "Shop", 12, 3, 10),
|
||||||
("wallet-tokens", "Wallet Tokens", 3, 3),
|
("wallet-balances", "Wallet Balances", 3, 3, 100),
|
||||||
("wallet-payment", "Payment Methods", 6, 3),
|
("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,
|
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):
|
def test_new_user_wallet_shows_starting_balances(self):
|
||||||
# 1. Log in as new user
|
# 1. Log in as new user
|
||||||
@@ -179,20 +220,214 @@ class WalletDisplayTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
self.assertEqual(rows, '3')
|
self.assertEqual(rows, '3')
|
||||||
|
|
||||||
def test_user_can_purchase_tithe_token_bundle(self):
|
def test_wallet_tokens_applet_shows_all_owned_trinket_types(self):
|
||||||
# 1. Log in, navigate to wallet page
|
"""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.create_pre_authenticated_session("capman@test.io")
|
||||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
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(
|
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
|
msg = portal.find_element(By.CSS_SELECTOR, ".guard-message").text
|
||||||
bundle = self.browser.find_element(
|
self.assertIn("Tithe", msg)
|
||||||
By.CSS_SELECTOR, "#id_tithe_token_shop .token-bundle"
|
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)
|
def test_shop_buy_guard_portal_pins_item_tooltip(self):
|
||||||
self.assertIn("Writ", bundle.text)
|
"""While the BUY-ITEM guard portal is open, the item's main +
|
||||||
# 5. (Placeholder) Purchase flow via Stripe not driven in this FT:
|
mini tooltip stay pinned — they don't dismiss when the cursor
|
||||||
# Full charge assertion deferred until Stripe webhook handling implemented
|
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="NoteSpec.js"></script>
|
||||||
<script src="NotePageSpec.js"></script>
|
<script src="NotePageSpec.js"></script>
|
||||||
<script src="RowLockSpec.js"></script>
|
<script src="RowLockSpec.js"></script>
|
||||||
|
<script src="WalletShopSpec.js"></script>
|
||||||
<!-- src files -->
|
<!-- src files -->
|
||||||
<script src="/static/apps/applets/row-lock.js"></script>
|
<script src="/static/apps/applets/row-lock.js"></script>
|
||||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||||
<script src="/static/apps/dashboard/note.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/billboard/note-page.js"></script>
|
||||||
<script src="/static/apps/epic/stage-card.js"></script>
|
<script src="/static/apps/epic/stage-card.js"></script>
|
||||||
<script src="/static/apps/epic/role-select.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-shoptalk { font-size: 0.75rem; opacity: 0.75; }
|
||||||
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
|
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
|
||||||
.tt-date { font-size: 1rem; color: rgba(var(--priGn), 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,
|
.token-tooltip,
|
||||||
|
|||||||
@@ -71,3 +71,106 @@
|
|||||||
transform: translateX(-50%);
|
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="NoteSpec.js"></script>
|
||||||
<script src="NotePageSpec.js"></script>
|
<script src="NotePageSpec.js"></script>
|
||||||
<script src="RowLockSpec.js"></script>
|
<script src="RowLockSpec.js"></script>
|
||||||
|
<script src="WalletShopSpec.js"></script>
|
||||||
<!-- src files -->
|
<!-- src files -->
|
||||||
<script src="/static/apps/applets/row-lock.js"></script>
|
<script src="/static/apps/applets/row-lock.js"></script>
|
||||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||||
<script src="/static/apps/dashboard/note.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/billboard/note-page.js"></script>
|
||||||
<script src="/static/apps/epic/stage-card.js"></script>
|
<script src="/static/apps/epic/stage-card.js"></script>
|
||||||
<script src="/static/apps/epic/role-select.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" %}
|
{% include "apps/wallet/_partials/_applets.html" %}
|
||||||
</div>
|
</div>
|
||||||
<div id="id_tooltip_portal" class="token-tooltip"></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="https://js.stripe.com/v3/"></script>
|
||||||
<script src="{% static "apps/dashboard/wallet.js" %}"></script>
|
<script src="{% static "apps/dashboard/wallet.js" %}"></script>
|
||||||
|
<script src="{% static "apps/dashboard/wallet-shop.js" %}"></script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -2,19 +2,13 @@
|
|||||||
id="id_wallet_balances"
|
id="id_wallet_balances"
|
||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
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>
|
<h2>Balances</h2>
|
||||||
<div><i class="fa-solid fa-ticket"></i>: <span id="id_writs_balance">{{ wallet.writs }}</span></div>
|
<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>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>
|
</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>
|
<h2>Tokens</h2>
|
||||||
<div class="token-row">
|
<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 %}
|
{% if pass_token %}
|
||||||
<div id="id_pass_token" class="token">
|
<div id="id_pass_token" class="token">
|
||||||
<i class="fa-solid fa-clipboard"></i>
|
<i class="fa-solid fa-clipboard"></i>
|
||||||
@@ -16,7 +22,8 @@
|
|||||||
<p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p>
|
<p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% elif band %}
|
{% endif %}
|
||||||
|
{% if band %}
|
||||||
<div id="id_band_token" class="token">
|
<div id="id_band_token" class="token">
|
||||||
<i class="fa-solid fa-ring"></i>
|
<i class="fa-solid fa-ring"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
@@ -28,7 +35,8 @@
|
|||||||
<p class="tt-expiry">{{ band.tooltip_expiry }}</p>
|
<p class="tt-expiry">{{ band.tooltip_expiry }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% elif coin %}
|
{% endif %}
|
||||||
|
{% if coin %}
|
||||||
<div id="id_coin_on_a_string" class="token">
|
<div id="id_coin_on_a_string" class="token">
|
||||||
<i class="fa-solid fa-medal"></i>
|
<i class="fa-solid fa-medal"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
@@ -41,6 +49,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if free_tokens %}
|
||||||
{% with free_tokens.0 as token %}
|
{% with free_tokens.0 as token %}
|
||||||
<div id="id_free_token" class="token">
|
<div id="id_free_token" class="token">
|
||||||
|
|||||||
Reference in New Issue
Block a user