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:
57
src/apps/lyric/migrations/0010_repricing_tithe_writs.py
Normal file
57
src/apps/lyric/migrations/0010_repricing_tithe_writs.py
Normal file
@@ -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),
|
||||
]
|
||||
18
src/apps/lyric/migrations/0011_shopitem_shoptalk.py
Normal file
18
src/apps/lyric/migrations/0011_shopitem_shoptalk.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
40
src/apps/lyric/migrations/0012_seed_shop_shoptalk.py
Normal file
40
src/apps/lyric/migrations/0012_seed_shop_shoptalk.py
Normal file
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user