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

@@ -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);
}
}

View File

@@ -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>

View 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();
});
});