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:
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
document.addEventListener('DOMContentLoaded', WalletTooltips.initWalletTooltips);
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user