From 25f55f728adcc52edbf1f5e3d7ce474e8dd0dd45 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 22 May 2026 02:21:10 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20wallet=20Shop=20polish=20=E2=80=94=20mi?= =?UTF-8?q?crotooltip=20extraction,=20Shop-first=20ordering,=20DRY=20toolt?= =?UTF-8?q?ip=20styling,=20writs=20rebalance,=20"no=20expiry"=20on=20all?= =?UTF-8?q?=20items.=20Visual-pass=20tweaks=20landing=20atop=20the=205-chu?= =?UTF-8?q?nk=20Shop=20rollout=20(commits=208e476f5=20=E2=86=92=20d28cf7b)?= =?UTF-8?q?.=20**Microtooltip=20extraction**:=20`.tt-microbutton-portal`?= =?UTF-8?q?=20(Chunk=204's=20wrap-inside-`.tt`)=20replaced=20w.=20a=20sibl?= =?UTF-8?q?ing=20`.tt-micro`=20div=20on=20each=20`.shop-tile`.=20`wallet.j?= =?UTF-8?q?s`'s=20`initWalletTooltips`=20clones=20BOTH=20into=20separate?= =?UTF-8?q?=20portals=20on=20hover=20=E2=80=94=20`.tt`=20=E2=86=92=20`#id?= =?UTF-8?q?=5Ftooltip=5Fportal`=20(main=20card),=20`.tt-micro`=20=E2=86=92?= =?UTF-8?q?=20`#id=5Fmini=5Ftooltip=5Fportal`=20(small=20italic=20pill=20a?= =?UTF-8?q?t=20bottom-right=20of=20main,=20mirroring=20Game=20Kit's=20Equi?= =?UTF-8?q?pped/Unequipped/In-Use=20mini=20portal).=20Hover=20persistence?= =?UTF-8?q?=20covers=20both=20portals=20+=20the=20source=20tile=20w.=20a?= =?UTF-8?q?=20200ms=20grace=20timer=20cancelled=20by=20mouseenter=20on=20a?= =?UTF-8?q?ny=20of=20the=203=20zones.=20Capped=20items=20(BAND-owned)=20re?= =?UTF-8?q?nder=20NO=20btn=20at=20all=20=E2=80=94=20just=20"Already=20owne?= =?UTF-8?q?d"=20microtext=20(mirrors=20Game=20Kit's=20status-only=20"Equip?= =?UTF-8?q?ped"=20pill=20rather=20than=20the=20disabled-=C3=97=20pattern?= =?UTF-8?q?=20that=20lived=20in=20Chunk=204).=20**Tooltip-pin=20on=20guard?= =?UTF-8?q?=20open**:=20`WalletTooltips.pin()`=20/=20`.unpin()`=20exposed?= =?UTF-8?q?=20on=20window;=20`wallet-shop.js`'s=20BUY=20click=20calls=20`p?= =?UTF-8?q?in()`=20before=20`showGuard()`=20+=20both=20`onConfirm`=20/=20`?= =?UTF-8?q?onDismiss`=20callbacks=20call=20`unpin()`=20=E2=86=92=20the=20i?= =?UTF-8?q?tem=20tooltip=20stays=20visible=20behind=20the=20guard's=20"Buy?= =?UTF-8?q?=20{name}=20for=20${price}=3F"=20prompt=20instead=20of=20orphan?= =?UTF-8?q?ing.=20**Shop-first=20applet=20ordering**:=20new=20`Applet.disp?= =?UTF-8?q?lay=5Forder`=20field=20(default=20100,=20lower=20=3D=20earlier;?= =?UTF-8?q?=20PK=20tie-break=20preserves=20legacy=20insertion-order=20for?= =?UTF-8?q?=20the=20existing=203=20applets);=20seed=20migration=20sets=20`?= =?UTF-8?q?wallet-shop.display=5Forder=3D10`=20so=20Shop=20renders=20atop?= =?UTF-8?q?=20Balances/Tokens/Payment.=20`applet=5Fcontext()`=20updated=20?= =?UTF-8?q?to=20`.order=5Fby("display=5Forder",=20"pk")`.=20New=20`WalletA?= =?UTF-8?q?ppletOrderTest`=20(2=20ITs)=20pins=20Shop-first=20DOM=20order?= =?UTF-8?q?=20+=20view-context=20list.=20**DRY=20tooltip=20styling**:=20sh?= =?UTF-8?q?op=20tooltip=20now=20uses=20the=20same=204-slot=20`.tt-title`?= =?UTF-8?q?=20/=20`.tt-description`=20/=20`.tt-shoptalk`=20/=20`.tt-expiry?= =?UTF-8?q?`=20classes=20as=20the=20Tokens=20row.=20New=20`ShopItem.shopta?= =?UTF-8?q?lk`=20field=20for=20the=20italic=20flavor=20line=20(band-1=20?= =?UTF-8?q?=3D=20"Unlimited=20free=20entry=20(BYOB)"=20split=20out=20of=20?= =?UTF-8?q?description;=20tithes=20blank).=20New=20`ShopItem.tooltip=5Fexp?= =?UTF-8?q?iry()`=20method=20returns=20"no=20expiry"=20=E2=80=94=20eternal?= =?UTF-8?q?-stock=20convention=20(all=20current=20items;=20seasonal=20list?= =?UTF-8?q?ings=20could=20override=20later).=20**Writs=20rebalance**:=20lo?= =?UTF-8?q?cked=202026-05-22=20=E2=80=94=20tithe-1=20144=E2=86=9212=20writ?= =?UTF-8?q?s,=20tithe-5=20750=E2=86=9260=20writs.=20Description=20text=20u?= =?UTF-8?q?pdated=20in=20lockstep=20("1=20Tithe=20Token=20+=2012=20Writs"?= =?UTF-8?q?=20/=20"5=20Tithe=20Tokens=20+=2060=20Writs").=20**Badge=20twea?= =?UTF-8?q?k**:=20=C3=97N=20badge=20shrunk=202rem=20=E2=86=92=201.5rem=20+?= =?UTF-8?q?=20nudged=20further=20off-tile=20(top:=20-0.7rem,=20right:=20-1?= =?UTF-8?q?rem)=20so=20most=20of=20the=20underlying=20icon=20stays=20visib?= =?UTF-8?q?le.=20**SCSS**:=20`.tt-micro`=20hidden=20in=20source=20DOM=20(p?= =?UTF-8?q?ortal-only);=20`#id=5Fmini=5Ftooltip=5Fportal`=20mostly=20mirro?= =?UTF-8?q?rs=20gameboard's=20mini=20at=20`=5Fgameboard.scss:140`=20but=20?= =?UTF-8?q?allows=20BUY-btn=20label=20to=20wrap=20onto=20multiple=20lines?= =?UTF-8?q?=20(`white-space:=20normal`=20on=20`.tt-buy-btn`);=20`.tt-alrea?= =?UTF-8?q?dy-owned`=20styled=20w.=20`--secUser`=20italic=20at=200.85rem?= =?UTF-8?q?=20to=20match=20Game=20Kit=20pills.=20**Migrations**=20?= =?UTF-8?q?=E2=80=94=205=20new:=20`lyric/0010=5Frepricing=5Ftithe=5Fwrits`?= =?UTF-8?q?=20(writs=20+=20description),=20`lyric/0011=5Fshopitem=5Fshopta?= =?UTF-8?q?lk`=20(schema),=20`lyric/0012=5Fseed=5Fshop=5Fshoptalk`=20(band?= =?UTF-8?q?=20split),=20`applets/0012=5Fapplet=5Fdisplay=5Forder`=20(schem?= =?UTF-8?q?a),=20`applets/0013=5Fwallet=5Fshop=5Fdisplay=5Forder`=20(Shop?= =?UTF-8?q?=20atop).=20All=20idempotent.=20**TDD**=20=E2=80=94=205=20new?= =?UTF-8?q?=20ITs=20across=20`test=5Fshop=5Fmodels.py`=20(`shoptalk`=20def?= =?UTF-8?q?ault=20+=20per-item=20assertions,=20`tooltip=5Fexpiry`=20method?= =?UTF-8?q?,=20updated=20tithe=20writs=20values,=20`WalletAppletOrderTest`?= =?UTF-8?q?),=201=20new=20FT=20(`test=5Fshop=5Fbuy=5Fguard=5Fportal=5Fpins?= =?UTF-8?q?=5Fitem=5Ftooltip`=20=E2=80=94=20programmatically=20dispatches?= =?UTF-8?q?=20mouseenter/mouseleave=20to=20exercise=20the=20pin/unpin=20ra?= =?UTF-8?q?ce),=203=20new=20Jasmine=20specs=20(T6=20pin-on-click,=20T7=20u?= =?UTF-8?q?npin-on-confirm,=20T8=20unpin-on-dismiss).=20Existing=20FT=20ba?= =?UTF-8?q?nd-owned=20assertion=20switched=20to=20`.tt-micro`=20(no=20`.tt?= =?UTF-8?q?-buy-btn`=20present),=20Jasmine=20T2=20rewritten=20to=20assert?= =?UTF-8?q?=20no=20btn=20renders.=20**3=20traps=20caught**=20mid-build:=20?= =?UTF-8?q?(a)=20multi-line=20`{#=20#}`=20comment=20leaked=20into=20DOM=20?= =?UTF-8?q?again=20(cf=20[[feedback-django-comments-single-line-only]])=20?= =?UTF-8?q?=E2=80=94=20pinned=20the=20trap;=20(b)=20`spyOn(window,=20'fetc?= =?UTF-8?q?h')`=20Jasmine=20double-spy=20collision=20(cf=20trapped=20previ?= =?UTF-8?q?ously);=20(c)=20async=20pollution=20where=20`afterEach`=20resto?= =?UTF-8?q?res=20`window.Stripe=3Dundefined`=20before=20`=5FdoBuy`'s=20con?= =?UTF-8?q?tinuation=20hits=20it=20=E2=80=94=20fixed=20by=20per-test=20nev?= =?UTF-8?q?er-resolving=20fetch=20mock.=201211=20IT/UT=20+=209=20wallet=20?= =?UTF-8?q?FTs=20green;=20Jasmine=20SpecRunner=20verified=20visually=20(FT?= =?UTF-8?q?=20hangs=20Selenium-side=20on=20spec=20count).=20Pipeline=20wil?= =?UTF-8?q?l=20sweep=20all=20FTs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrations/0012_applet_display_order.py | 18 +++ .../0013_wallet_shop_display_order.py | 31 +++++ src/apps/applets/models.py | 5 + src/apps/applets/utils.py | 11 +- .../static/apps/dashboard/wallet-shop.js | 56 +++++++-- .../dashboard/static/apps/dashboard/wallet.js | 118 +++++++++++++++--- .../tests/integrated/test_shop_views.py | 13 +- .../tests/integrated/test_wallet_views.py | 31 +++++ .../migrations/0010_repricing_tithe_writs.py | 57 +++++++++ .../migrations/0011_shopitem_shoptalk.py | 18 +++ .../migrations/0012_seed_shop_shoptalk.py | 40 ++++++ src/apps/lyric/models.py | 12 ++ .../tests/integrated/test_shop_models.py | 36 +++++- src/functional_tests/test_dash_wallet.py | 115 +++++++++++++---- src/static/tests/WalletShopSpec.js | 90 +++++++++---- src/static_src/scss/_wallet-tokens.scss | 67 +++++++--- src/static_src/tests/WalletShopSpec.js | 90 +++++++++---- src/templates/apps/dashboard/wallet.html | 3 + .../wallet/_partials/_applet-wallet-shop.html | 47 ++++--- 19 files changed, 709 insertions(+), 149 deletions(-) create mode 100644 src/apps/applets/migrations/0012_applet_display_order.py create mode 100644 src/apps/applets/migrations/0013_wallet_shop_display_order.py create mode 100644 src/apps/lyric/migrations/0010_repricing_tithe_writs.py create mode 100644 src/apps/lyric/migrations/0011_shopitem_shoptalk.py create mode 100644 src/apps/lyric/migrations/0012_seed_shop_shoptalk.py diff --git a/src/apps/applets/migrations/0012_applet_display_order.py b/src/apps/applets/migrations/0012_applet_display_order.py new file mode 100644 index 0000000..a0d47c1 --- /dev/null +++ b/src/apps/applets/migrations/0012_applet_display_order.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-22 05:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applets', '0011_seed_wallet_shop_applet'), + ] + + operations = [ + migrations.AddField( + model_name='applet', + name='display_order', + field=models.PositiveSmallIntegerField(default=100), + ), + ] diff --git a/src/apps/applets/migrations/0013_wallet_shop_display_order.py b/src/apps/applets/migrations/0013_wallet_shop_display_order.py new file mode 100644 index 0000000..385038c --- /dev/null +++ b/src/apps/applets/migrations/0013_wallet_shop_display_order.py @@ -0,0 +1,31 @@ +"""Pin the wallet Shop applet atop the wallet row. + +The 4-applet wallet layout (per [[project-wallet-shop-expansion]]) wants +Shop first; the other 3 (Balances, Tokens, Payment) keep their historical +order via the default `display_order=100` + PK tie-break. + +Idempotent — `update_or_create(slug=…, defaults={display_order: 10})` +also covers fresh DBs where `0011_seed_wallet_shop_applet` already ran. +""" +from django.db import migrations + + +def forward(apps, schema_editor): + Applet = apps.get_model("applets", "Applet") + Applet.objects.filter(slug="wallet-shop").update(display_order=10) + + +def reverse(apps, schema_editor): + Applet = apps.get_model("applets", "Applet") + Applet.objects.filter(slug="wallet-shop").update(display_order=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ("applets", "0012_applet_display_order"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/src/apps/applets/models.py b/src/apps/applets/models.py index d7ec370..0eaa374 100644 --- a/src/apps/applets/models.py +++ b/src/apps/applets/models.py @@ -18,6 +18,11 @@ class Applet(models.Model): default_visible = models.BooleanField(default=True) grid_cols = models.PositiveSmallIntegerField(default=12) grid_rows = models.PositiveSmallIntegerField(default=3) + # Render-time sort key. Lower = earlier in the applets row. Default 100 + # gives every existing applet a tied position → falls back to PK insertion + # order (the historical behavior), so this field is backwards-compatible. + # Set to <100 to pin an applet ABOVE the rest (eg. wallet-shop = 10). + display_order = models.PositiveSmallIntegerField(default=100) def __str__(self): return self.name diff --git a/src/apps/applets/utils.py b/src/apps/applets/utils.py index c61f096..874abb8 100644 --- a/src/apps/applets/utils.py +++ b/src/apps/applets/utils.py @@ -13,9 +13,12 @@ def apply_applet_toggle(user, context, checked_slugs): def applet_context(user, context): ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()} - applets = {a.slug: a for a in Applet.objects.filter(context=context)} + # `display_order` (lower = earlier) is the primary sort key; `pk` tie-breaks + # so applets at the default order=100 keep their historical insertion-order + # rendering. New applets that want pinned positions set order < 100 in + # their seed migration (eg. wallet-shop = 10 to render atop the wallet row). + applets_qs = Applet.objects.filter(context=context).order_by("display_order", "pk") return [ - {"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)} - for slug in applets - if slug in applets + {"applet": a, "visible": ua_map.get(a.pk, a.default_visible)} + for a in applets_qs ] diff --git a/src/apps/dashboard/static/apps/dashboard/wallet-shop.js b/src/apps/dashboard/static/apps/dashboard/wallet-shop.js index 50e70aa..767e302 100644 --- a/src/apps/dashboard/static/apps/dashboard/wallet-shop.js +++ b/src/apps/dashboard/static/apps/dashboard/wallet-shop.js @@ -82,13 +82,15 @@ const WalletShop = (function () { 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'); + // The btn may be the original (inside .shop-tile) OR the portal + // clone (sibling of .wallet-page) — `closest('.shop-tile')` only + // works on the former. Read every datum from the btn itself + look + // up the shop root via document.querySelector (singleton). + const shopRoot = document.querySelector('.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); + const slug = btn.dataset.shopItemSlug; + const name = btn.dataset.itemName || slug; + const priceCents = parseInt(btn.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)) @@ -96,9 +98,31 @@ const WalletShop = (function () { : '$' + dollars.toFixed(2); const message = 'Buy ' + name + ' for ' + priceStr + '?'; if (typeof window.showGuard === 'function') { - window.showGuard(btn, message, function () { - _doBuy(slug, btn, shopRoot); - }); + // Pin the wallet tooltip so the item card + microbtn stay + // visible while the guard portal is open — otherwise the + // cursor moving from the BUY btn down to the guard's OK/NVM + // triggers the tooltip's mouseleave, leaving the guard's + // "Buy {name} for ${price}?" prompt floating w. no referent. + // Both callbacks (confirm + dismiss) unpin so the next + // mouseleave (or the unpin-triggered _scheduleHide) dismisses + // the tooltip after the user resolves the prompt. + if (window.WalletTooltips && typeof window.WalletTooltips.pin === 'function') { + window.WalletTooltips.pin(); + } + window.showGuard( + btn, message, + function () { // onConfirm + if (window.WalletTooltips && window.WalletTooltips.unpin) { + window.WalletTooltips.unpin(); + } + _doBuy(slug, btn, shopRoot); + }, + function () { // onDismiss + if (window.WalletTooltips && window.WalletTooltips.unpin) { + window.WalletTooltips.unpin(); + } + }, + ); } } @@ -109,11 +133,17 @@ const WalletShop = (function () { // 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. + // Triple delegation — the BUY btn click can come from: + // (a) original tile (inside `.shop-tile`) — shopRoot listener + // (b) cloned main portal — kept for legacy / non-mini-portal pages + // (c) cloned mini portal (`#id_mini_tooltip_portal`) — production + // path post-microtooltip refactor; the BUY btn lives in + // `.tt-micro` which clones into the mini portal on hover. shopRoot.addEventListener('click', _onBuyClick); + const portal = document.getElementById('id_tooltip_portal'); + if (portal) portal.addEventListener('click', _onBuyClick); + const miniPortal = document.getElementById('id_mini_tooltip_portal'); + if (miniPortal) miniPortal.addEventListener('click', _onBuyClick); shopRoot.dataset.shopWired = '1'; } diff --git a/src/apps/dashboard/static/apps/dashboard/wallet.js b/src/apps/dashboard/static/apps/dashboard/wallet.js index 6c4b6bd..a747468 100644 --- a/src/apps/dashboard/static/apps/dashboard/wallet.js +++ b/src/apps/dashboard/static/apps/dashboard/wallet.js @@ -64,31 +64,113 @@ const initWallet = () => { }); }; +// `WalletTooltips` module — exposes pin/unpin so other JS (eg. wallet-shop.js' +// BUY-flow guard portal) can hold the wallet tooltip open across user +// interactions that would otherwise dismiss it. The internals (hide +// timer, _show/_hide helpers) live inside the singleton's IIFE — only +// pin/unpin/initWalletTooltips are public. +const WalletTooltips = (function () { + 'use strict'; + + let _hideTimer = null; + let _pinned = false; + const HIDE_DELAY_MS = 200; + let _portal = null; + let _miniPortal = null; + + function _hideAll() { + if (_portal) _portal.classList.remove('active'); + if (_miniPortal) _miniPortal.classList.remove('active'); + } + + function _cancelHide() { + if (_hideTimer) { clearTimeout(_hideTimer); _hideTimer = null; } + } + + function _scheduleHide() { + // Pinned (eg. guard portal open) — suppress the hide. unpin() will + // call _scheduleHide() again to dismiss after the guard closes. + if (_pinned) return; + _cancelHide(); + _hideTimer = setTimeout(() => { _hideAll(); _hideTimer = null; }, HIDE_DELAY_MS); + } + + function pin() { _pinned = true; _cancelHide(); } + function unpin(){ _pinned = false; _scheduleHide(); } + function initWalletTooltips() { const portal = document.getElementById('id_tooltip_portal'); if (!portal) return; + _portal = portal; + // Mini portal — used by shop tiles (BUY ITEM / "Already owned" pill). + // Tokens applet tiles have no `.tt-micro` sibling so the mini stays + // hidden on those hovers. Mirrors gameboard.html's mini portal. + const miniPortal = document.getElementById('id_mini_tooltip_portal'); + _miniPortal = miniPortal; - document.querySelectorAll('.wallet-tokens .token').forEach(token => { - const tooltip = token.querySelector('.tt'); + // Hover-persistence — keep the portal(s) open while the cursor moves + // from tile → portal → mini-portal so users can click the BUY-ITEM + // microbutton. A short hide delay covers the gap between + // mouseleave-on-tile and mouseenter-on-portal; entering any of the + // 3 zones cancels the hide. + + function _show(anchor, tooltipHtml, microHtml) { + _cancelHide(); + const rect = anchor.getBoundingClientRect(); + portal.innerHTML = tooltipHtml; + portal.classList.add('active'); + const halfW = portal.offsetWidth / 2; + const rawLeft = rect.left + rect.width / 2; + const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8)); + portal.style.left = Math.round(clampedLeft) + 'px'; + portal.style.top = Math.round(rect.top) + 'px'; + portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))'; + + if (miniPortal && microHtml) { + miniPortal.innerHTML = microHtml; + miniPortal.classList.add('active'); + // Pin mini-portal to the bottom-right of the main portal — + // same anchor pattern as gameboard.js's gameKit tooltips. + const mainRect = portal.getBoundingClientRect(); + miniPortal.style.left = ''; + miniPortal.style.right = Math.round(window.innerWidth - mainRect.right) + 'px'; + miniPortal.style.top = (mainRect.bottom + 4) + 'px'; + } else if (miniPortal) { + miniPortal.classList.remove('active'); + } + } + + function _bindHover(anchor) { + const tooltip = anchor.querySelector('.tt'); if (!tooltip) return; + // `.tt-micro` is a SIBLING of `.tt` (not a child) so it lives + // alongside the main tooltip content without nesting — keeps the + // BUY-ITEM btn visually distinct in the mini portal. + const micro = anchor.querySelector(':scope > .tt-micro'); + const microHtml = micro ? micro.innerHTML : null; + anchor.addEventListener('mouseenter', () => _show(anchor, tooltip.innerHTML, microHtml)); + anchor.addEventListener('mouseleave', _scheduleHide); + } - token.addEventListener('mouseenter', () => { - const rect = token.getBoundingClientRect(); - portal.innerHTML = tooltip.innerHTML; - portal.classList.add('active'); - const halfW = portal.offsetWidth / 2; - const rawLeft = rect.left + rect.width / 2; - const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8)); - portal.style.left = Math.round(clampedLeft) + 'px'; - portal.style.top = Math.round(rect.top) + 'px'; - portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))'; - }); + document.querySelectorAll('.wallet-tokens .token, .wallet-shop .shop-tile') + .forEach(_bindHover); - token.addEventListener('mouseleave', () => { - portal.classList.remove('active'); - }); - }); + // Re-entering either portal cancels the pending hide — keeps the + // microbutton clickable. Leaving either restarts the hide timer. + portal.addEventListener('mouseenter', _cancelHide); + portal.addEventListener('mouseleave', _scheduleHide); + if (miniPortal) { + miniPortal.addEventListener('mouseenter', _cancelHide); + miniPortal.addEventListener('mouseleave', _scheduleHide); + } } + return { initWalletTooltips: initWalletTooltips, pin: pin, unpin: unpin }; +})(); + +// Expose globally so wallet-shop.js can call WalletTooltips.pin/unpin +// without depending on script-load order. +window.WalletTooltips = WalletTooltips; + document.addEventListener('DOMContentLoaded', initWallet); -document.addEventListener('DOMContentLoaded', initWalletTooltips); \ No newline at end of file +document.addEventListener('DOMContentLoaded', WalletTooltips.initWalletTooltips); \ No newline at end of file diff --git a/src/apps/dashboard/tests/integrated/test_shop_views.py b/src/apps/dashboard/tests/integrated/test_shop_views.py index 010dfbd..a4e63bd 100644 --- a/src/apps/dashboard/tests/integrated/test_shop_views.py +++ b/src/apps/dashboard/tests/integrated/test_shop_views.py @@ -31,10 +31,10 @@ def _seed_starting_items(): ShopItem.objects.update_or_create( slug="tithe-1", defaults={ - "name": "Tithe Token", "description": "1 Tithe + 144 Writs", + "name": "Tithe Token", "description": "1 Tithe Token + 12 Writs", "icon": "fa-piggy-bank", "badge_text": "", "price_cents": 100, "granted_token_type": Token.TITHE, - "granted_count": 1, "granted_writs": 144, + "granted_count": 1, "granted_writs": 12, "max_owned": None, "display_order": 10, "active": True, }, ) @@ -42,6 +42,7 @@ def _seed_starting_items(): slug="band-1", defaults={ "name": "Wristband", "description": "Admit All Entry", + "shoptalk": "Unlimited free entry (BYOB)", "icon": "fa-ring", "badge_text": "", "price_cents": 2000, "granted_token_type": Token.BAND, "granted_count": 1, "granted_writs": 0, @@ -89,7 +90,7 @@ class ShopBuyViewTest(TestCase): self.assertEqual(purchase.status, Purchase.PENDING) self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc") self.assertEqual(purchase.amount_cents, 100) - self.assertEqual(purchase.granted_writs, 144) + self.assertEqual(purchase.granted_writs, 12) @mock.patch("apps.dashboard.views.stripe") def test_payment_intent_called_with_correct_args(self, mock_stripe): @@ -155,7 +156,7 @@ class ShopConfirmViewTest(TestCase): self.purchase = Purchase.objects.create( user=self.user, shop_item=self.tithe, stripe_payment_intent_id="pi_conf_1", - amount_cents=100, granted_writs=144, + amount_cents=100, granted_writs=12, ) def test_requires_login(self): @@ -215,7 +216,7 @@ class ShopConfirmViewTest(TestCase): other_purchase = Purchase.objects.create( user=other, shop_item=self.tithe, stripe_payment_intent_id="pi_other", - amount_cents=100, granted_writs=144, + amount_cents=100, granted_writs=12, ) response = self.client.post( "/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk}, @@ -234,7 +235,7 @@ class StripeWebhookViewTest(TestCase): self.purchase = Purchase.objects.create( user=self.user, shop_item=self.tithe, stripe_payment_intent_id="pi_wh_1", - amount_cents=100, granted_writs=144, + amount_cents=100, granted_writs=12, ) @mock.patch("apps.dashboard.views.stripe") diff --git a/src/apps/dashboard/tests/integrated/test_wallet_views.py b/src/apps/dashboard/tests/integrated/test_wallet_views.py index 46a0552..b2b691b 100644 --- a/src/apps/dashboard/tests/integrated/test_wallet_views.py +++ b/src/apps/dashboard/tests/integrated/test_wallet_views.py @@ -106,6 +106,37 @@ class WalletTokensAppletAllTrinketsVisibleTest(TestCase): [_] = parsed.cssselect("#id_carte_token") +class WalletAppletOrderTest(TestCase): + """The wallet row renders Shop first, then Balances/Tokens/Payment in + their historical insertion order — pinned via `Applet.display_order` + (lower = earlier; default 100 + PK tie-break preserves the legacy + order for the rest). Bug-prevention pin: a future migration that + renames or reseeds applets must keep wallet-shop at order < 100. + See [[project-wallet-shop-expansion]] for the locked layout spec.""" + + def setUp(self): + self.user = User.objects.create(email="layout@test.io") + self.client.force_login(self.user) + + def test_shop_applet_renders_first_in_wallet_row(self): + response = self.client.get("/dashboard/wallet/") + html = response.content.decode() + shop_pos = html.find('id="id_wallet_shop"') + balances_pos = html.find('id="id_wallet_balances"') + tokens_pos = html.find('id_writs_balance') # inside balances applet + # Shop's id_wallet_shop appears before Balances' id_wallet_balances + self.assertGreater(shop_pos, 0) + self.assertGreater(balances_pos, 0) + self.assertLess(shop_pos, balances_pos) + self.assertLess(shop_pos, tokens_pos) + + def test_shop_applet_first_in_context_list(self): + """View-context shape pin: `applets` is a list ordered Shop-first.""" + response = self.client.get("/dashboard/wallet/") + slugs = [e["applet"].slug for e in response.context["applets"]] + self.assertEqual(slugs[0], "wallet-shop") + + class WalletPassTokenVisibilityTest(TestCase): """PASS is admin-only — the model guard blocks bogus rows from existing for non-staff users, but defend the wallet surface too so a future diff --git a/src/apps/lyric/migrations/0010_repricing_tithe_writs.py b/src/apps/lyric/migrations/0010_repricing_tithe_writs.py new file mode 100644 index 0000000..cb0d2bd --- /dev/null +++ b/src/apps/lyric/migrations/0010_repricing_tithe_writs.py @@ -0,0 +1,57 @@ +"""Re-balance the Tithe shop items' writs payout. + +User-locked 2026-05-22: `tithe-1` drops 144 → 12 Writs, `tithe-5` drops +750 → 60 Writs. Description strings updated in lockstep so the tooltip +prose tracks the new numbers. + +`granted_token_type`, `granted_count`, `price_cents`, `max_owned`, +`display_order` all unchanged. Only the writs grant + the description +text shift. +""" +from django.db import migrations + + +_UPDATES = [ + { + "slug": "tithe-1", + "description": "1 Tithe Token + 12 Writs", + "granted_writs": 12, + }, + { + "slug": "tithe-5", + "description": "5 Tithe Tokens + 60 Writs", + "granted_writs": 60, + }, +] +_OLD = [ + {"slug": "tithe-1", "description": "1 Tithe Token + 144 Writs", "granted_writs": 144}, + {"slug": "tithe-5", "description": "5 Tithe Tokens + 750 Writs", "granted_writs": 750}, +] + + +def _apply(apps, specs): + ShopItem = apps.get_model("lyric", "ShopItem") + for spec in specs: + ShopItem.objects.filter(slug=spec["slug"]).update( + description=spec["description"], + granted_writs=spec["granted_writs"], + ) + + +def forward(apps, schema_editor): + _apply(apps, _UPDATES) + + +def reverse(apps, schema_editor): + _apply(apps, _OLD) + + +class Migration(migrations.Migration): + + dependencies = [ + ("lyric", "0009_seed_shop_items"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/src/apps/lyric/migrations/0011_shopitem_shoptalk.py b/src/apps/lyric/migrations/0011_shopitem_shoptalk.py new file mode 100644 index 0000000..46c42e4 --- /dev/null +++ b/src/apps/lyric/migrations/0011_shopitem_shoptalk.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-22 06:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0010_repricing_tithe_writs'), + ] + + operations = [ + migrations.AddField( + model_name='shopitem', + name='shoptalk', + field=models.CharField(blank=True, default='', max_length=200), + ), + ] diff --git a/src/apps/lyric/migrations/0012_seed_shop_shoptalk.py b/src/apps/lyric/migrations/0012_seed_shop_shoptalk.py new file mode 100644 index 0000000..fc1c5c2 --- /dev/null +++ b/src/apps/lyric/migrations/0012_seed_shop_shoptalk.py @@ -0,0 +1,40 @@ +"""Populate `shoptalk` for existing shop items + split BAND's description. + +Pre-migration: `band-1.description` carried both the game function +("Admit All Entry") + the italic flavor ("unlimited free entry (BYOB)") +crammed onto one line. The wallet shop tooltip now uses the DRY four- +slot pattern of `.tt-title` / `.tt-description` / `.tt-shoptalk` / +`.tt-expiry` — same classes the Tokens row already styles — so the +flavor line moves to its own `shoptalk` field, mirroring how +`Token.tooltip_shoptalk` separates flavor from description. + +Tithes don't carry shoptalk (their Token tooltips don't either). +""" +from django.db import migrations + + +def forward(apps, schema_editor): + ShopItem = apps.get_model("lyric", "ShopItem") + ShopItem.objects.filter(slug="band-1").update( + description="Admit All Entry", + shoptalk="Unlimited free entry (BYOB)", + ) + + +def reverse(apps, schema_editor): + ShopItem = apps.get_model("lyric", "ShopItem") + ShopItem.objects.filter(slug="band-1").update( + description="Admit All Entry — unlimited free entry (BYOB)", + shoptalk="", + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("lyric", "0011_shopitem_shoptalk"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 16d6061..c5c7e38 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -344,6 +344,10 @@ class ShopItem(models.Model): slug = models.SlugField(unique=True) name = models.CharField(max_length=100) description = models.TextField(blank=True, default="") + # `shoptalk` is the italic flavor line that mirrors `Token.tooltip_shoptalk` — + # rendered via the `.tt-shoptalk` SCSS class (DRY w. the wallet's Token row). + # Blank → the `{% if item.shoptalk %}` slot in the template is skipped. + shoptalk = models.CharField(max_length=200, blank=True, default="") icon = models.CharField(max_length=50) # FA icon class (eg "fa-piggy-bank") badge_text = models.CharField(max_length=8, blank=True, default="") # eg "×5"; "" = no badge price_cents = models.PositiveIntegerField() @@ -383,6 +387,14 @@ class ShopItem(models.Model): return f"${int(dollars)}" return f"${dollars:.2f}" + def tooltip_expiry(self): + """All shop items are eternal stock (no time-bound listings yet) so + the tooltip's `.tt-expiry` slot always shows 'no expiry' — same + red-callout styling as PASS/BAND/CARTE token tooltips. If a future + seasonal item needs a real expiry, override on the row + return + the formatted string here.""" + return "no expiry" + class Purchase(models.Model): """Audit-trail row for one shop transaction. Created at PENDING on diff --git a/src/apps/lyric/tests/integrated/test_shop_models.py b/src/apps/lyric/tests/integrated/test_shop_models.py index 5cf4f36..bc1dd80 100644 --- a/src/apps/lyric/tests/integrated/test_shop_models.py +++ b/src/apps/lyric/tests/integrated/test_shop_models.py @@ -67,6 +67,25 @@ class ShopItemModelTest(TestCase): ) self.assertEqual(str(item), "Wristband") + def test_shoptalk_defaults_to_blank(self): + """Most items have no shoptalk (italic flavor line) — defaults to "" + so the tooltip's `{% if item.shoptalk %}` skips the slot cleanly.""" + item = ShopItem.objects.create( + slug="probe", name="Probe", price_cents=100, + granted_token_type=Token.TITHE, granted_count=1, + ) + self.assertEqual(item.shoptalk, "") + + def test_tooltip_expiry_returns_no_expiry(self): + """All shop items are eternal stock — the tooltip's expiry slot + always reads 'no expiry' (matches PASS/BAND/CARTE token tooltips, + DRY-reuses `.tt-expiry` SCSS for the red callout).""" + item = ShopItem.objects.create( + slug="probe", name="Probe", price_cents=100, + granted_token_type=Token.TITHE, granted_count=1, + ) + self.assertEqual(item.tooltip_expiry(), "no expiry") + def test_is_available_for_unlimited_item(self): """`max_owned=None` → item is always available.""" item = ShopItem.objects.create( @@ -221,14 +240,17 @@ class SeededShopCatalogTest(TestCase): self.assertEqual(item.price_cents, 100) self.assertEqual(item.granted_token_type, Token.TITHE) self.assertEqual(item.granted_count, 1) - self.assertEqual(item.granted_writs, 144) + # Re-balanced 2026-05-22 (migration `0010_repricing_tithe_writs`): + # 144 → 12 writs per tithe-1 purchase. + self.assertEqual(item.granted_writs, 12) self.assertIsNone(item.max_owned) def test_tithe_five_bundle_item_present(self): item = ShopItem.objects.get(slug="tithe-5") self.assertEqual(item.price_cents, 400) self.assertEqual(item.granted_count, 5) - self.assertEqual(item.granted_writs, 750) + # Re-balanced 2026-05-22: 750 → 60 writs per bundle purchase. + self.assertEqual(item.granted_writs, 60) self.assertEqual(item.badge_text, "×5") def test_band_item_present(self): @@ -238,6 +260,16 @@ class SeededShopCatalogTest(TestCase): self.assertEqual(item.granted_count, 1) self.assertEqual(item.max_owned, 1) self.assertEqual(item.granted_writs, 0) + # BAND carries the italic shoptalk line from the Token tooltip — + # DRY w. `Token.tooltip_shoptalk` for the BAND type. + self.assertEqual(item.shoptalk, "Unlimited free entry (BYOB)") + + def test_tithe_items_have_no_shoptalk(self): + """Tithes don't carry italic flavor in the Token tooltip; shop + mirrors that — `.tt-shoptalk` slot is empty + the template's + `{% if %}` skips the line cleanly.""" + self.assertEqual(ShopItem.objects.get(slug="tithe-1").shoptalk, "") + self.assertEqual(ShopItem.objects.get(slug="tithe-5").shoptalk, "") def test_all_three_items_active(self): for slug in ("tithe-1", "tithe-5", "band-1"): diff --git a/src/functional_tests/test_dash_wallet.py b/src/functional_tests/test_dash_wallet.py index 1edbbc6..c408748 100644 --- a/src/functional_tests/test_dash_wallet.py +++ b/src/functional_tests/test_dash_wallet.py @@ -10,15 +10,18 @@ class WalletDisplayTest(FunctionalTest): def setUp(self): super().setUp() Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"}) - for slug, name, cols, rows in [ - ("wallet-balances", "Wallet Balances", 3, 3), - ("wallet-tokens", "Wallet Tokens", 3, 3), - ("wallet-payment", "Payment Methods", 6, 3), - ("wallet-shop", "Shop", 12, 3), + for slug, name, cols, rows, order in [ + ("wallet-shop", "Shop", 12, 3, 10), + ("wallet-balances", "Wallet Balances", 3, 3, 100), + ("wallet-tokens", "Wallet Tokens", 3, 3, 100), + ("wallet-payment", "Payment Methods", 6, 3, 100), ]: - Applet.objects.get_or_create( + Applet.objects.update_or_create( slug=slug, - defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "wallet"}, + defaults={ + "name": name, "grid_cols": cols, "grid_rows": rows, + "context": "wallet", "display_order": order, + }, ) # Seed the 3 starting ShopItems — migration `lyric/0009_seed_shop_items` # populates these in fresh DBs, but FTs run on `TransactionTestCase` @@ -28,20 +31,20 @@ class WalletDisplayTest(FunctionalTest): ShopItem.objects.update_or_create( slug="tithe-1", defaults={ - "name": "Tithe Token", "description": "1 Tithe + 144 Writs", + "name": "Tithe Token", "description": "1 Tithe Token + 12 Writs", "icon": "fa-piggy-bank", "badge_text": "", "price_cents": 100, "granted_token_type": Token.TITHE, - "granted_count": 1, "granted_writs": 144, + "granted_count": 1, "granted_writs": 12, "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", + "name": "Tithe Bundle", "description": "5 Tithe Tokens + 60 Writs", "icon": "fa-piggy-bank", "badge_text": "×5", "price_cents": 400, "granted_token_type": Token.TITHE, - "granted_count": 5, "granted_writs": 750, + "granted_count": 5, "granted_writs": 60, "max_owned": None, "display_order": 20, "active": True, }, ) @@ -49,6 +52,7 @@ class WalletDisplayTest(FunctionalTest): slug="band-1", defaults={ "name": "Wristband", "description": "Admit All Entry", + "shoptalk": "Unlimited free entry (BYOB)", "icon": "fa-ring", "badge_text": "", "price_cents": 2000, "granted_token_type": Token.BAND, "granted_count": 1, "granted_writs": 0, @@ -325,6 +329,77 @@ class WalletDisplayTest(FunctionalTest): lambda: self.assertNotIn("active", portal.get_attribute("class")) ) + def test_shop_buy_guard_portal_pins_item_tooltip(self): + """While the BUY-ITEM guard portal is open, the item's main + + mini tooltip stay pinned — they don't dismiss when the cursor + leaves the BUY btn area to reach the guard's OK / NVM. Fixes + the orphan-prompt UX where "Buy {item} for ${price}?" floated + on its own w. no visual referent. Pinning is released on + either confirm OR dismiss; both schedule the normal hide.""" + import time + 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. Programmatically trigger the tile-enter → both portals active. + self.browser.execute_script( + "document.getElementById('id_shop_tithe-5').dispatchEvent(" + "new MouseEvent('mouseenter', {bubbles: true}));" + ) + self.wait_for( + lambda: self.assertIn( + "active", + self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"), + ) + ) + self.assertIn( + "active", + self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"), + ) + # 2. Click the cloned BUY btn inside the mini portal — opens guard. + self.browser.execute_script( + "document.querySelector('#id_mini_tooltip_portal .tt-buy-btn').click();" + ) + guard = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_guard_portal") + ) + self.wait_for( + lambda: self.assertIn("active", guard.get_attribute("class")) + ) + # 3. Simulate cursor leaving both portals to reach the guard btns. + self.browser.execute_script(""" + document.getElementById('id_mini_tooltip_portal').dispatchEvent( + new MouseEvent('mouseleave', {bubbles: true})); + document.getElementById('id_tooltip_portal').dispatchEvent( + new MouseEvent('mouseleave', {bubbles: true})); + """) + # 4. Wait longer than the wallet.js hide delay (200ms) + buffer. + time.sleep(0.4) + # 5. Both portals are STILL active — pinned by the open guard. + self.assertIn( + "active", + self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"), + ) + self.assertIn( + "active", + self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"), + ) + # 6. Click NVM — guard dismisses, pin releases. + guard.find_element(By.CSS_SELECTOR, ".guard-no").click() + self.wait_for( + lambda: self.assertNotIn("active", guard.get_attribute("class")) + ) + # 7. After unpin + hide delay, portals fade out. + time.sleep(0.4) + self.assertNotIn( + "active", + self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"), + ) + self.assertNotIn( + "active", + self.browser.find_element(By.ID, "id_mini_tooltip_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 @@ -336,16 +411,14 @@ class WalletDisplayTest(FunctionalTest): 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) + # Capped item — NO buy btn at all (parity w. Game Kit's status- + # only "Equipped" / "In-Use: X" pills, which never pair status + # text w. a disabled action btn). + self.assertEqual(band_tile.find_elements(By.CSS_SELECTOR, ".tt-buy-btn"), []) + # 'Already owned' microtext lives in the `.tt-micro` sibling-of-.tt + # (cloned into `#id_mini_tooltip_portal` on hover by wallet.js). + micro_html = band_tile.find_element(By.CSS_SELECTOR, ".tt-micro").get_attribute("innerHTML") + self.assertIn("Already owned", micro_html) # Legacy `test_user_can_purchase_tithe_token_bundle` FT (asserting diff --git a/src/static/tests/WalletShopSpec.js b/src/static/tests/WalletShopSpec.js index 9a2d818..a6a1eda 100644 --- a/src/static/tests/WalletShopSpec.js +++ b/src/static/tests/WalletShopSpec.js @@ -32,36 +32,28 @@ function _seedShopFixture() { root.dataset.defaultPaymentMethodId = 'pm_test_4242'; root.dataset.stripePublishableKey = 'pk_test_fixture'; root.innerHTML = ` -
+

Tithe Token

1 Tithe + 144 Writs

$1

-
- -
+
+
+
-
+

Wristband

$20

-
- -

Already owned

-
+
+
+ Already owned
`; @@ -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(); + }); }); diff --git a/src/static_src/scss/_wallet-tokens.scss b/src/static_src/scss/_wallet-tokens.scss index f679dfc..2a567f9 100644 --- a/src/static_src/scss/_wallet-tokens.scss +++ b/src/static_src/scss/_wallet-tokens.scss @@ -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; } } diff --git a/src/static_src/tests/WalletShopSpec.js b/src/static_src/tests/WalletShopSpec.js index 9a2d818..a6a1eda 100644 --- a/src/static_src/tests/WalletShopSpec.js +++ b/src/static_src/tests/WalletShopSpec.js @@ -32,36 +32,28 @@ function _seedShopFixture() { root.dataset.defaultPaymentMethodId = 'pm_test_4242'; root.dataset.stripePublishableKey = 'pk_test_fixture'; root.innerHTML = ` -
+

Tithe Token

1 Tithe + 144 Writs

$1

-
- -
+
+
+
-
+

Wristband

$20

-
- -

Already owned

-
+
+
+ Already owned
`; @@ -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(); + }); }); diff --git a/src/templates/apps/dashboard/wallet.html b/src/templates/apps/dashboard/wallet.html index a0f4c82..4249880 100644 --- a/src/templates/apps/dashboard/wallet.html +++ b/src/templates/apps/dashboard/wallet.html @@ -10,6 +10,9 @@ {% include "apps/wallet/_partials/_applets.html" %}
+ {# Microtooltip for the Shop applet's BUY-ITEM btn / 'Already owned' note. #} + {# Mirrors gameboard.html's mini portal (Equipped/Unequipped/In-Use). #} +
diff --git a/src/templates/apps/wallet/_partials/_applet-wallet-shop.html b/src/templates/apps/wallet/_partials/_applet-wallet-shop.html index 507bc62..164d1fd 100644 --- a/src/templates/apps/wallet/_partials/_applet-wallet-shop.html +++ b/src/templates/apps/wallet/_partials/_applet-wallet-shop.html @@ -20,26 +20,39 @@ {{ item.badge_text }} {% endif %}
+ {# DRY-reuses the Tokens row's 4-slot tooltip pattern — same #} + {# `.tt-title/.tt-description/.tt-shoptalk/.tt-expiry` SCSS #} + {# classes so shop + token tooltips render as the same widget.#}

{{ item.name }}

{{ item.description }}

+ {% if item.shoptalk %} +

{{ item.shoptalk }}

+ {% endif %}

{{ item.price_display }}

-
- {% if item.available %} - - {% else %} - -

Already owned

- {% endif %} -
+

{{ item.tooltip_expiry }}

+
+ {% comment %} + Sibling-of-.tt microtooltip — mirrors Game Kit's + Equipped/Unequipped/In-Use mini portal pattern. wallet.js's + tooltip handler clones this into #id_mini_tooltip_portal on + hover; staying separate keeps the BUY-ITEM btn visually + distinct from the main name+price card. + {% endcomment %} +
+ {% if item.available %} + + {% else %} + {# Capped item — no BUY btn at all (parity w. Game Kit's #} + {# 'In-Use: X' / 'Equipped' microtext pattern, which never #} + {# pairs status text w. a disabled action btn). #} + Already owned + {% endif %}
{% endfor %}