feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug wallet-shop, seeded in Chunk 2) now renders the catalog as a horizontal grid of .shop-tile icons: tithe-1 ($1, fa-piggy-bank), tithe-5 ($4, fa-piggy-bank w. ×5 badge), band-1 ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a .tt-microbutton-portal w. a .btn-primary BUY ITEM button — clicking opens #id_guard_portal w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit max_owned (eg. BAND, owned=1, cap=1) render w. .btn-disabled + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — wallet view + toggle_wallet_applets view both pass shop_items (decorated w. per-user .available via the new _shop_items_for(user) helper) + default_payment_method_id + stripe_publishable_key. SCSS — .wallet-shop (flex column wrapping .shop-grid flex row), .shop-tile (inline-flex tooltip target), .shop-badge (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), .tt-microbutton-portal (column-flex, BUY btn + 'Already owned' caption styling). JS in wallet-shop.js exposes a singleton WalletShop module (matching the project's Brief / SeaDeal / StageCard module pattern) w. a tested initWalletShop() method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed data-shop-wired flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into wallet.html after wallet.js. **TDD** — 5 Jasmine specs in WalletShopSpec.js: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs shop_item_slug to /shop/buy; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) spyOn(window, 'fetch') collides if another spec already spied on fetch — switched to save+restore via per-test _origFetch capture; (b) T3 async pollution — sync assertion passed, afterEach restored window.Stripe=undefined, then _doBuy's async continuation hit Stripe(pubKey) and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in test_dash_wallet.py: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via textContent since .tt is display: none). FT trap caught: TransactionTestCase wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors test_shop_views.py's _seed_starting_items pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-22 01:15:05 -04:00
parent 410664fb0f
commit 81b3c112b4
10 changed files with 712 additions and 8 deletions

View File

@@ -0,0 +1,123 @@
// 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();
const tile = btn.closest('.shop-tile');
if (!tile) return;
const shopRoot = btn.closest('.wallet-shop');
if (!shopRoot) return;
const slug = btn.dataset.shopItemSlug || tile.dataset.shopItemSlug;
const name = tile.dataset.itemName || slug;
const priceCents = parseInt(tile.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') {
window.showGuard(btn, message, function () {
_doBuy(slug, btn, shopRoot);
});
}
}
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;
// Single delegated click listener at the shop-root level so the
// microtooltip-portal (rendered outside the tile by the portal
// tooltip pattern in `wallet.js`) still hits this handler when
// the buy btn is hovered into a portaled position.
shopRoot.addEventListener('click', _onBuyClick);
shopRoot.dataset.shopWired = '1';
}
return { initWalletShop: initWalletShop };
})();
document.addEventListener('DOMContentLoaded', WalletShop.initWalletShop);

View File

@@ -155,6 +155,18 @@ def toggle_applets(request):
})
return redirect("home")
def _shop_items_for(user):
"""Decorate the active ShopItem catalog w. per-user availability so the
template can render `.btn-disabled` + 'Already owned' microtooltip
for `max_owned`-capped items the user already holds. Items are returned
in `display_order` ASC (matches the seeded `tithe-1` < `tithe-5` < `band-1`)."""
items = []
for item in ShopItem.objects.filter(active=True).order_by("display_order", "slug"):
item.available = item.is_available_for(user)
items.append(item)
return items
@login_required(login_url="/")
@ensure_csrf_cookie
def wallet(request):
@@ -162,12 +174,17 @@ def wallet(request):
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
shop_items = _shop_items_for(request.user)
default_pm = request.user.payment_methods.order_by("-pk").first()
return render(request, "apps/dashboard/wallet.html", {
"wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"shop_items": shop_items,
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
"free_tokens": free_tokens,
"tithe_tokens": tithe_tokens,
"free_count": len(free_tokens),
@@ -199,6 +216,7 @@ def toggle_wallet_applets(request):
checked = request.POST.getlist("applets")
apply_applet_toggle(request.user, "wallet", checked)
if request.headers.get("HX-Request"):
default_pm = request.user.payment_methods.order_by("-pk").first()
return render(request, "apps/wallet/_partials/_applets.html", {
"applets": applet_context(request.user, "wallet"),
"wallet": request.user.wallet,
@@ -206,6 +224,9 @@ def toggle_wallet_applets(request):
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"shop_items": _shop_items_for(request.user),
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
})