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 = ` -
1 Tithe + 144 Writs
$1
- +$20
- +1 Tithe + 144 Writs
$1
- +$20
- +{{ item.description }}
+ {% if item.shoptalk %} +{{ item.shoptalk }}
+ {% endif %}{{ item.price_display }}
- +{{ item.tooltip_expiry }}
+