diff --git a/src/apps/dashboard/static/apps/dashboard/wallet-shop.js b/src/apps/dashboard/static/apps/dashboard/wallet-shop.js new file mode 100644 index 0000000..50e70aa --- /dev/null +++ b/src/apps/dashboard/static/apps/dashboard/wallet-shop.js @@ -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); diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index feb35da..4b23aa3 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -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)), }) diff --git a/src/functional_tests/test_dash_wallet.py b/src/functional_tests/test_dash_wallet.py index c4c44c7..9c73055 100644 --- a/src/functional_tests/test_dash_wallet.py +++ b/src/functional_tests/test_dash_wallet.py @@ -3,7 +3,7 @@ from selenium.webdriver.common.by import By from .base import FunctionalTest from apps.applets.models import Applet -from apps.lyric.models import Token, User +from apps.lyric.models import ShopItem, Token, User class WalletDisplayTest(FunctionalTest): @@ -14,11 +14,47 @@ class WalletDisplayTest(FunctionalTest): ("wallet-balances", "Wallet Balances", 3, 3), ("wallet-tokens", "Wallet Tokens", 3, 3), ("wallet-payment", "Payment Methods", 6, 3), + ("wallet-shop", "Shop", 12, 3), ]: Applet.objects.get_or_create( slug=slug, defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "wallet"}, ) + # 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 + 144 Writs", + "icon": "fa-piggy-bank", "badge_text": "", + "price_cents": 100, "granted_token_type": Token.TITHE, + "granted_count": 1, "granted_writs": 144, + "max_owned": None, "display_order": 10, "active": True, + }, + ) + ShopItem.objects.update_or_create( + slug="tithe-5", + defaults={ + "name": "Tithe Bundle", "description": "5 Tithe Tokens + 750 Writs", + "icon": "fa-piggy-bank", "badge_text": "×5", + "price_cents": 400, "granted_token_type": Token.TITHE, + "granted_count": 5, "granted_writs": 750, + "max_owned": None, "display_order": 20, "active": True, + }, + ) + ShopItem.objects.update_or_create( + slug="band-1", + defaults={ + "name": "Wristband", "description": "Admit All Entry", + "icon": "fa-ring", "badge_text": "", + "price_cents": 2000, "granted_token_type": Token.BAND, + "granted_count": 1, "granted_writs": 0, + "max_owned": 1, "display_order": 30, "active": True, + }, + ) def test_new_user_wallet_shows_starting_balances(self): # 1. Log in as new user @@ -224,6 +260,94 @@ class WalletDisplayTest(FunctionalTest): self.assertIn("no expiry", carte_tt) + def test_shop_applet_renders_seeded_items_with_icons_and_badges(self): + """Chunk 4 of [[project-wallet-shop-expansion]] — the new Shop + applet renders the 3 seeded items (`tithe-1`, `tithe-5`, `band-1`) + as tooltipped tiles. The bundle (`tithe-5`) carries a `×5` badge; + the single-tithe + band tiles render without a badge. + + Pinned visual contract: tile presence (id), icon class, badge text, + and price text inside the tooltip prose.""" + self.create_pre_authenticated_session("capman@test.io") + self.browser.get(self.live_server_url + "/dashboard/wallet/") + # 1. Shop applet is present + self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop")) + # 2. Each of the 3 seeded items renders w. its own tile + tithe1 = self.browser.find_element(By.ID, "id_shop_tithe-1") + tithe5 = self.browser.find_element(By.ID, "id_shop_tithe-5") + band1 = self.browser.find_element(By.ID, "id_shop_band-1") + # 3. Icons are correct + self.assertIn("fa-piggy-bank", tithe1.find_element(By.CSS_SELECTOR, "i").get_attribute("class")) + self.assertIn("fa-piggy-bank", tithe5.find_element(By.CSS_SELECTOR, "i").get_attribute("class")) + self.assertIn("fa-ring", band1.find_element(By.CSS_SELECTOR, "i").get_attribute("class")) + # 4. Bundle carries the ×5 badge; singles don't + self.assertIn("×5", tithe5.find_element(By.CSS_SELECTOR, ".shop-badge").text) + self.assertEqual(tithe1.find_elements(By.CSS_SELECTOR, ".shop-badge"), []) + self.assertEqual(band1.find_elements(By.CSS_SELECTOR, ".shop-badge"), []) + # 5. Tooltip prose includes name + price (read .tt innerHTML directly + # — hover→portal cloning is already exercised by the existing + # COIN/FREE hover tests). + tithe1_tt = tithe1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML") + self.assertIn("Tithe Token", tithe1_tt) + self.assertIn("$1", tithe1_tt) + band1_tt = band1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML") + self.assertIn("Wristband", band1_tt) + self.assertIn("$20", band1_tt) + + def test_shop_buy_click_opens_guard_portal_with_purchase_prompt(self): + """BUY ITEM click opens `#id_guard_portal` w. an 'Are you sure?'-style + prompt naming the item + price. Click NVM dismisses; click OK + triggers the Stripe.js dance (mocked out / deferred to a follow-up + FT).""" + self.create_pre_authenticated_session("capman@test.io") + self.browser.get(self.live_server_url + "/dashboard/wallet/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop")) + # 1. Find BUY ITEM btn inside the tithe-1 tile's microtooltip + buy_btn = self.browser.find_element( + By.CSS_SELECTOR, "#id_shop_tithe-1 .tt-buy-btn" + ) + # 2. Click it via JS (microtooltip lives in the portal layer + may + # not be hover-visible during Selenium's strict-target check) + self.browser.execute_script("arguments[0].click();", buy_btn) + # 3. Guard portal opens w. a prompt mentioning Tithe + $1 + portal = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_guard_portal") + ) + self.wait_for( + lambda: self.assertIn("active", portal.get_attribute("class")) + ) + msg = portal.find_element(By.CSS_SELECTOR, ".guard-message").text + self.assertIn("Tithe", msg) + self.assertIn("$1", msg) + # 4. NVM dismisses + portal.find_element(By.CSS_SELECTOR, ".guard-no").click() + self.wait_for( + lambda: self.assertNotIn("active", portal.get_attribute("class")) + ) + + 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") + # BUY btn rendered as `.btn-disabled` w. × glyph (parity w. game- + # kit's disabled DON/DOFF buttons). Read via `textContent` because + # `.tt` is `display: none` by default + Selenium's `.text` returns + # empty for hidden subtrees. + buy_btn = band_tile.find_element(By.CSS_SELECTOR, ".tt-buy-btn") + self.assertIn("btn-disabled", buy_btn.get_attribute("class")) + self.assertEqual(buy_btn.get_attribute("textContent").strip(), "×") + # Microtooltip swap signals why it's disabled + tt_html = band_tile.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML") + self.assertIn("Already owned", tt_html) + + def test_user_can_purchase_tithe_token_bundle(self): # 1. Log in, navigate to wallet page self.create_pre_authenticated_session("capman@test.io") diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html index 87819f0..41adf9f 100644 --- a/src/static/tests/SpecRunner.html +++ b/src/static/tests/SpecRunner.html @@ -29,10 +29,12 @@ + + diff --git a/src/static/tests/WalletShopSpec.js b/src/static/tests/WalletShopSpec.js new file mode 100644 index 0000000..9a2d818 --- /dev/null +++ b/src/static/tests/WalletShopSpec.js @@ -0,0 +1,161 @@ +// ── 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 = ` +
1 Tithe + 144 Writs
+$1
+ +$20
+ +1 Tithe + 144 Writs
+$1
+ +$20
+ +{{ item.description }}
+{{ item.price_display }}
+ +