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:
123
src/apps/dashboard/static/apps/dashboard/wallet-shop.js
Normal file
123
src/apps/dashboard/static/apps/dashboard/wallet-shop.js
Normal 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);
|
||||
@@ -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)),
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
<script src="NoteSpec.js"></script>
|
||||
<script src="NotePageSpec.js"></script>
|
||||
<script src="RowLockSpec.js"></script>
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/dashboard/note.js"></script>
|
||||
<script src="/static/apps/dashboard/wallet-shop.js"></script>
|
||||
<script src="/static/apps/billboard/note-page.js"></script>
|
||||
<script src="/static/apps/epic/stage-card.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
|
||||
161
src/static/tests/WalletShopSpec.js
Normal file
161
src/static/tests/WalletShopSpec.js
Normal file
@@ -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 = `
|
||||
<div id="id_shop_tithe-1"
|
||||
class="shop-tile"
|
||||
data-shop-item-slug="tithe-1"
|
||||
data-price-cents="100"
|
||||
data-item-name="Tithe Token">
|
||||
<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 class="tt-microbutton-portal">
|
||||
<button class="btn btn-primary tt-buy-btn"
|
||||
data-shop-item-slug="tithe-1">BUY ITEM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="id_shop_band-1"
|
||||
class="shop-tile"
|
||||
data-shop-item-slug="band-1"
|
||||
data-price-cents="2000"
|
||||
data-item-name="Wristband">
|
||||
<i class="fa-solid fa-ring"></i>
|
||||
<div class="tt">
|
||||
<h4 class="tt-title">Wristband</h4>
|
||||
<p class="tt-price">$20</p>
|
||||
<div class="tt-microbutton-portal">
|
||||
<button class="btn btn-primary tt-buy-btn btn-disabled"
|
||||
data-shop-item-slug="band-1">×</button>
|
||||
<p class="tt-already-owned"><em>Already owned</em></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
|
||||
describe('WalletShop.initWalletShop', () => {
|
||||
let fixture;
|
||||
let _origFetch;
|
||||
let _origStripe;
|
||||
let _origShowGuard;
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.remove();
|
||||
window.fetch = _origFetch;
|
||||
window.Stripe = _origStripe;
|
||||
window.showGuard = _origShowGuard;
|
||||
});
|
||||
|
||||
// ── 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 ── click on disabled BUY does NOT open guard ─────────────────────
|
||||
it('T2: clicking the .btn-disabled BUY (band-1, already-owned) is a no-op', () => {
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_band-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
expect(window.showGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── 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();
|
||||
});
|
||||
});
|
||||
@@ -71,3 +71,79 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// ×5 badge — top-right corner, --quaUser glyph on --quiUser bg, 2rem circle.
|
||||
// Per-locked spec from [[project-wallet-shop-expansion]].
|
||||
.shop-badge {
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
right: -0.75rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--quiUser), 1);
|
||||
color: rgba(var(--quaUser), 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Microtooltip — the buy-btn lives inside the main tooltip portal, styled
|
||||
// like Game Kit's `#id_mini_tooltip_portal` (Equipped/Unequipped/In-Use).
|
||||
// Hover persistence (cursor moves from tile → portal → microbutton without
|
||||
// dismissing the tooltip) is handled by `wallet-shop.js`.
|
||||
.tt-microbutton-portal {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
.tt-buy-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.tt-already-owned {
|
||||
font-size: 0.7rem;
|
||||
margin: 0;
|
||||
color: rgba(var(--terUser), 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
<script src="NoteSpec.js"></script>
|
||||
<script src="NotePageSpec.js"></script>
|
||||
<script src="RowLockSpec.js"></script>
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/dashboard/note.js"></script>
|
||||
<script src="/static/apps/dashboard/wallet-shop.js"></script>
|
||||
<script src="/static/apps/billboard/note-page.js"></script>
|
||||
<script src="/static/apps/epic/stage-card.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
|
||||
161
src/static_src/tests/WalletShopSpec.js
Normal file
161
src/static_src/tests/WalletShopSpec.js
Normal file
@@ -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 = `
|
||||
<div id="id_shop_tithe-1"
|
||||
class="shop-tile"
|
||||
data-shop-item-slug="tithe-1"
|
||||
data-price-cents="100"
|
||||
data-item-name="Tithe Token">
|
||||
<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 class="tt-microbutton-portal">
|
||||
<button class="btn btn-primary tt-buy-btn"
|
||||
data-shop-item-slug="tithe-1">BUY ITEM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="id_shop_band-1"
|
||||
class="shop-tile"
|
||||
data-shop-item-slug="band-1"
|
||||
data-price-cents="2000"
|
||||
data-item-name="Wristband">
|
||||
<i class="fa-solid fa-ring"></i>
|
||||
<div class="tt">
|
||||
<h4 class="tt-title">Wristband</h4>
|
||||
<p class="tt-price">$20</p>
|
||||
<div class="tt-microbutton-portal">
|
||||
<button class="btn btn-primary tt-buy-btn btn-disabled"
|
||||
data-shop-item-slug="band-1">×</button>
|
||||
<p class="tt-already-owned"><em>Already owned</em></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
|
||||
describe('WalletShop.initWalletShop', () => {
|
||||
let fixture;
|
||||
let _origFetch;
|
||||
let _origStripe;
|
||||
let _origShowGuard;
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.remove();
|
||||
window.fetch = _origFetch;
|
||||
window.Stripe = _origStripe;
|
||||
window.showGuard = _origShowGuard;
|
||||
});
|
||||
|
||||
// ── 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 ── click on disabled BUY does NOT open guard ─────────────────────
|
||||
it('T2: clicking the .btn-disabled BUY (band-1, already-owned) is a no-op', () => {
|
||||
WalletShop.initWalletShop();
|
||||
const btn = fixture.querySelector('#id_shop_band-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
expect(window.showGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── 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();
|
||||
});
|
||||
});
|
||||
@@ -12,4 +12,5 @@
|
||||
<div id="id_tooltip_portal" class="token-tooltip"></div>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script src="{% static "apps/dashboard/wallet.js" %}"></script>
|
||||
<script src="{% static "apps/dashboard/wallet-shop.js" %}"></script>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -2,13 +2,46 @@
|
||||
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 }}"
|
||||
>
|
||||
{% comment %}
|
||||
Stub. Chunk 2 of the wallet-expansion sprint ships the row + model
|
||||
catalog; Chunk 4 fills in shop-tile rendering + BUY-ITEM microtooltip
|
||||
+ Stripe.js wiring. Keep the <section> element + id_wallet_shop hook
|
||||
so any test that just checks for shop visibility passes from Chunk 2
|
||||
onward (TDD's "ship the skeleton, fill the body next" pattern).
|
||||
{% endcomment %}
|
||||
<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">
|
||||
<h4 class="tt-title">{{ item.name }}</h4>
|
||||
<p class="tt-description">{{ item.description }}</p>
|
||||
<p class="tt-price">{{ item.price_display }}</p>
|
||||
<div class="tt-microbutton-portal">
|
||||
{% if item.available %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary tt-buy-btn"
|
||||
data-shop-item-slug="{{ item.slug }}"
|
||||
>BUY ITEM</button>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary tt-buy-btn btn-disabled"
|
||||
data-shop-item-slug="{{ item.slug }}"
|
||||
aria-disabled="true"
|
||||
>×</button>
|
||||
<p class="tt-already-owned"><em>Already owned</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user