feat: wallet Shop polish — microtooltip extraction, Shop-first ordering, DRY tooltip styling, writs rebalance, "no expiry" on all items. Visual-pass tweaks landing atop the 5-chunk Shop rollout (commits 8e476f5 → d28cf7b). **Microtooltip extraction**: .tt-microbutton-portal (Chunk 4's wrap-inside-.tt) replaced w. a sibling .tt-micro div on each .shop-tile. wallet.js's initWalletTooltips clones BOTH into separate portals on hover — .tt → #id_tooltip_portal (main card), .tt-micro → #id_mini_tooltip_portal (small italic pill at bottom-right of main, mirroring Game Kit's Equipped/Unequipped/In-Use mini portal). Hover persistence covers both portals + the source tile w. a 200ms grace timer cancelled by mouseenter on any of the 3 zones. Capped items (BAND-owned) render NO btn at all — just "Already owned" microtext (mirrors Game Kit's status-only "Equipped" pill rather than the disabled-× pattern that lived in Chunk 4). **Tooltip-pin on guard open**: WalletTooltips.pin() / .unpin() exposed on window; wallet-shop.js's BUY click calls pin() before showGuard() + both onConfirm / onDismiss callbacks call unpin() → the item tooltip stays visible behind the guard's "Buy {name} for ${price}?" prompt instead of orphaning. **Shop-first applet ordering**: new Applet.display_order field (default 100, lower = earlier; PK tie-break preserves legacy insertion-order for the existing 3 applets); seed migration sets wallet-shop.display_order=10 so Shop renders atop Balances/Tokens/Payment. applet_context() updated to .order_by("display_order", "pk"). New WalletAppletOrderTest (2 ITs) pins Shop-first DOM order + view-context list. **DRY tooltip styling**: shop tooltip now uses the same 4-slot .tt-title / .tt-description / .tt-shoptalk / .tt-expiry classes as the Tokens row. New ShopItem.shoptalk field for the italic flavor line (band-1 = "Unlimited free entry (BYOB)" split out of description; tithes blank). New ShopItem.tooltip_expiry() method returns "no expiry" — eternal-stock convention (all current items; seasonal listings could override later). **Writs rebalance**: locked 2026-05-22 — tithe-1 144→12 writs, tithe-5 750→60 writs. Description text updated in lockstep ("1 Tithe Token + 12 Writs" / "5 Tithe Tokens + 60 Writs"). **Badge tweak**: ×N badge shrunk 2rem → 1.5rem + nudged further off-tile (top: -0.7rem, right: -1rem) so most of the underlying icon stays visible. **SCSS**: .tt-micro hidden in source DOM (portal-only); #id_mini_tooltip_portal mostly mirrors gameboard's mini at _gameboard.scss:140 but allows BUY-btn label to wrap onto multiple lines (white-space: normal on .tt-buy-btn); .tt-already-owned styled w. --secUser italic at 0.85rem to match Game Kit pills. **Migrations** — 5 new: lyric/0010_repricing_tithe_writs (writs + description), lyric/0011_shopitem_shoptalk (schema), lyric/0012_seed_shop_shoptalk (band split), applets/0012_applet_display_order (schema), applets/0013_wallet_shop_display_order (Shop atop). All idempotent. **TDD** — 5 new ITs across test_shop_models.py (shoptalk default + per-item assertions, tooltip_expiry method, updated tithe writs values, WalletAppletOrderTest), 1 new FT (test_shop_buy_guard_portal_pins_item_tooltip — programmatically dispatches mouseenter/mouseleave to exercise the pin/unpin race), 3 new Jasmine specs (T6 pin-on-click, T7 unpin-on-confirm, T8 unpin-on-dismiss). Existing FT band-owned assertion switched to .tt-micro (no .tt-buy-btn present), Jasmine T2 rewritten to assert no btn renders. **3 traps caught** mid-build: (a) multi-line {# #} comment leaked into DOM again (cf [[feedback-django-comments-single-line-only]]) — pinned the trap; (b) spyOn(window, 'fetch') Jasmine double-spy collision (cf trapped previously); (c) async pollution where afterEach restores window.Stripe=undefined before _doBuy's continuation hits it — fixed by per-test never-resolving fetch mock. 1211 IT/UT + 9 wallet FTs green; Jasmine SpecRunner verified visually (FT hangs Selenium-side on spec count). Pipeline will sweep all FTs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -105,45 +105,72 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
// ×5 badge — top-right corner, --quaUser glyph on --quiUser bg, 2rem circle.
|
||||
// Per-locked spec from [[project-wallet-shop-expansion]].
|
||||
// ×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.5rem;
|
||||
right: -0.75rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
top: -0.8rem;
|
||||
right: -1.2rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--quiUser), 1);
|
||||
color: rgba(var(--quaUser), 1);
|
||||
background: rgba(var(--secUser), 1);
|
||||
color: rgba(var(--priUser), 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
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;
|
||||
// `.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 {
|
||||
font-size: 0.75rem;
|
||||
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.7rem;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
color: rgba(var(--terUser), 0.85);
|
||||
font-style: italic;
|
||||
color: rgba(var(--secUser), 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.active { display: flex; }
|
||||
}
|
||||
|
||||
|
||||
@@ -32,36 +32,28 @@ function _seedShopFixture() {
|
||||
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">
|
||||
<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 class="tt-microbutton-portal">
|
||||
<button class="btn btn-primary tt-buy-btn"
|
||||
data-shop-item-slug="tithe-1">BUY ITEM</button>
|
||||
</div>
|
||||
</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"
|
||||
data-shop-item-slug="band-1"
|
||||
data-price-cents="2000"
|
||||
data-item-name="Wristband">
|
||||
<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 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 class="tt-micro">
|
||||
<span class="tt-already-owned">Already owned</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -75,6 +67,7 @@ describe('WalletShop.initWalletShop', () => {
|
||||
let _origFetch;
|
||||
let _origStripe;
|
||||
let _origShowGuard;
|
||||
let _origWalletTooltips;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = _seedShopFixture();
|
||||
@@ -93,6 +86,13 @@ describe('WalletShop.initWalletShop', () => {
|
||||
});
|
||||
_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(() => {
|
||||
@@ -100,6 +100,7 @@ describe('WalletShop.initWalletShop', () => {
|
||||
window.fetch = _origFetch;
|
||||
window.Stripe = _origStripe;
|
||||
window.showGuard = _origShowGuard;
|
||||
window.WalletTooltips = _origWalletTooltips;
|
||||
});
|
||||
|
||||
// ── T1 ── click on enabled BUY opens guard portal w. price prompt ───────
|
||||
@@ -115,12 +116,18 @@ describe('WalletShop.initWalletShop', () => {
|
||||
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', () => {
|
||||
// ── 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();
|
||||
const btn = fixture.querySelector('#id_shop_band-1 .tt-buy-btn');
|
||||
btn.click();
|
||||
expect(window.showGuard).not.toHaveBeenCalled();
|
||||
// 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 ───────
|
||||
@@ -158,4 +165,39 @@ describe('WalletShop.initWalletShop', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user