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:
18
src/apps/applets/migrations/0012_applet_display_order.py
Normal file
18
src/apps/applets/migrations/0012_applet_display_order.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -18,6 +18,11 @@ class Applet(models.Model):
|
|||||||
default_visible = models.BooleanField(default=True)
|
default_visible = models.BooleanField(default=True)
|
||||||
grid_cols = models.PositiveSmallIntegerField(default=12)
|
grid_cols = models.PositiveSmallIntegerField(default=12)
|
||||||
grid_rows = models.PositiveSmallIntegerField(default=3)
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ def apply_applet_toggle(user, context, checked_slugs):
|
|||||||
|
|
||||||
def applet_context(user, context):
|
def applet_context(user, context):
|
||||||
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
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 [
|
return [
|
||||||
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
|
{"applet": a, "visible": ua_map.get(a.pk, a.default_visible)}
|
||||||
for slug in applets
|
for a in applets_qs
|
||||||
if slug in applets
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -82,13 +82,15 @@ const WalletShop = (function () {
|
|||||||
if (btn.classList.contains('btn-disabled')) return;
|
if (btn.classList.contains('btn-disabled')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const tile = btn.closest('.shop-tile');
|
// The btn may be the original (inside .shop-tile) OR the portal
|
||||||
if (!tile) return;
|
// clone (sibling of .wallet-page) — `closest('.shop-tile')` only
|
||||||
const shopRoot = btn.closest('.wallet-shop');
|
// 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;
|
if (!shopRoot) return;
|
||||||
const slug = btn.dataset.shopItemSlug || tile.dataset.shopItemSlug;
|
const slug = btn.dataset.shopItemSlug;
|
||||||
const name = tile.dataset.itemName || slug;
|
const name = btn.dataset.itemName || slug;
|
||||||
const priceCents = parseInt(tile.dataset.priceCents || '0', 10);
|
const priceCents = parseInt(btn.dataset.priceCents || '0', 10);
|
||||||
// Render "$1" / "$4" / "$20.50" — trim ".00" for whole-dollar prices.
|
// Render "$1" / "$4" / "$20.50" — trim ".00" for whole-dollar prices.
|
||||||
const dollars = priceCents / 100;
|
const dollars = priceCents / 100;
|
||||||
const priceStr = (dollars === Math.floor(dollars))
|
const priceStr = (dollars === Math.floor(dollars))
|
||||||
@@ -96,9 +98,31 @@ const WalletShop = (function () {
|
|||||||
: '$' + dollars.toFixed(2);
|
: '$' + dollars.toFixed(2);
|
||||||
const message = 'Buy ' + name + ' for ' + priceStr + '?';
|
const message = 'Buy ' + name + ' for ' + priceStr + '?';
|
||||||
if (typeof window.showGuard === 'function') {
|
if (typeof window.showGuard === 'function') {
|
||||||
window.showGuard(btn, message, function () {
|
// 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);
|
_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.
|
// module-scope flag so per-test fixture rebuilds re-wire cleanly.
|
||||||
// Calling twice on the SAME root is still a safe no-op (idempotent).
|
// Calling twice on the SAME root is still a safe no-op (idempotent).
|
||||||
if (shopRoot.dataset.shopWired === '1') return;
|
if (shopRoot.dataset.shopWired === '1') return;
|
||||||
// Single delegated click listener at the shop-root level so the
|
// Triple delegation — the BUY btn click can come from:
|
||||||
// microtooltip-portal (rendered outside the tile by the portal
|
// (a) original tile (inside `.shop-tile`) — shopRoot listener
|
||||||
// tooltip pattern in `wallet.js`) still hits this handler when
|
// (b) cloned main portal — kept for legacy / non-mini-portal pages
|
||||||
// the buy btn is hovered into a portaled position.
|
// (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);
|
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';
|
shopRoot.dataset.shopWired = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,17 +64,60 @@ 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() {
|
function initWalletTooltips() {
|
||||||
const portal = document.getElementById('id_tooltip_portal');
|
const portal = document.getElementById('id_tooltip_portal');
|
||||||
if (!portal) return;
|
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 => {
|
// Hover-persistence — keep the portal(s) open while the cursor moves
|
||||||
const tooltip = token.querySelector('.tt');
|
// from tile → portal → mini-portal so users can click the BUY-ITEM
|
||||||
if (!tooltip) return;
|
// 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.
|
||||||
|
|
||||||
token.addEventListener('mouseenter', () => {
|
function _show(anchor, tooltipHtml, microHtml) {
|
||||||
const rect = token.getBoundingClientRect();
|
_cancelHide();
|
||||||
portal.innerHTML = tooltip.innerHTML;
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
portal.innerHTML = tooltipHtml;
|
||||||
portal.classList.add('active');
|
portal.classList.add('active');
|
||||||
const halfW = portal.offsetWidth / 2;
|
const halfW = portal.offsetWidth / 2;
|
||||||
const rawLeft = rect.left + rect.width / 2;
|
const rawLeft = rect.left + rect.width / 2;
|
||||||
@@ -82,13 +125,52 @@ function initWalletTooltips() {
|
|||||||
portal.style.left = Math.round(clampedLeft) + 'px';
|
portal.style.left = Math.round(clampedLeft) + 'px';
|
||||||
portal.style.top = Math.round(rect.top) + 'px';
|
portal.style.top = Math.round(rect.top) + 'px';
|
||||||
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||||
});
|
|
||||||
|
|
||||||
token.addEventListener('mouseleave', () => {
|
if (miniPortal && microHtml) {
|
||||||
portal.classList.remove('active');
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.wallet-tokens .token, .wallet-shop .shop-tile')
|
||||||
|
.forEach(_bindHover);
|
||||||
|
|
||||||
|
// 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', initWallet);
|
||||||
document.addEventListener('DOMContentLoaded', initWalletTooltips);
|
document.addEventListener('DOMContentLoaded', WalletTooltips.initWalletTooltips);
|
||||||
@@ -31,10 +31,10 @@ def _seed_starting_items():
|
|||||||
ShopItem.objects.update_or_create(
|
ShopItem.objects.update_or_create(
|
||||||
slug="tithe-1",
|
slug="tithe-1",
|
||||||
defaults={
|
defaults={
|
||||||
"name": "Tithe Token", "description": "1 Tithe + 144 Writs",
|
"name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
|
||||||
"icon": "fa-piggy-bank", "badge_text": "",
|
"icon": "fa-piggy-bank", "badge_text": "",
|
||||||
"price_cents": 100, "granted_token_type": Token.TITHE,
|
"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,
|
"max_owned": None, "display_order": 10, "active": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -42,6 +42,7 @@ def _seed_starting_items():
|
|||||||
slug="band-1",
|
slug="band-1",
|
||||||
defaults={
|
defaults={
|
||||||
"name": "Wristband", "description": "Admit All Entry",
|
"name": "Wristband", "description": "Admit All Entry",
|
||||||
|
"shoptalk": "Unlimited free entry (BYOB)",
|
||||||
"icon": "fa-ring", "badge_text": "",
|
"icon": "fa-ring", "badge_text": "",
|
||||||
"price_cents": 2000, "granted_token_type": Token.BAND,
|
"price_cents": 2000, "granted_token_type": Token.BAND,
|
||||||
"granted_count": 1, "granted_writs": 0,
|
"granted_count": 1, "granted_writs": 0,
|
||||||
@@ -89,7 +90,7 @@ class ShopBuyViewTest(TestCase):
|
|||||||
self.assertEqual(purchase.status, Purchase.PENDING)
|
self.assertEqual(purchase.status, Purchase.PENDING)
|
||||||
self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc")
|
self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc")
|
||||||
self.assertEqual(purchase.amount_cents, 100)
|
self.assertEqual(purchase.amount_cents, 100)
|
||||||
self.assertEqual(purchase.granted_writs, 144)
|
self.assertEqual(purchase.granted_writs, 12)
|
||||||
|
|
||||||
@mock.patch("apps.dashboard.views.stripe")
|
@mock.patch("apps.dashboard.views.stripe")
|
||||||
def test_payment_intent_called_with_correct_args(self, mock_stripe):
|
def test_payment_intent_called_with_correct_args(self, mock_stripe):
|
||||||
@@ -155,7 +156,7 @@ class ShopConfirmViewTest(TestCase):
|
|||||||
self.purchase = Purchase.objects.create(
|
self.purchase = Purchase.objects.create(
|
||||||
user=self.user, shop_item=self.tithe,
|
user=self.user, shop_item=self.tithe,
|
||||||
stripe_payment_intent_id="pi_conf_1",
|
stripe_payment_intent_id="pi_conf_1",
|
||||||
amount_cents=100, granted_writs=144,
|
amount_cents=100, granted_writs=12,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_requires_login(self):
|
def test_requires_login(self):
|
||||||
@@ -215,7 +216,7 @@ class ShopConfirmViewTest(TestCase):
|
|||||||
other_purchase = Purchase.objects.create(
|
other_purchase = Purchase.objects.create(
|
||||||
user=other, shop_item=self.tithe,
|
user=other, shop_item=self.tithe,
|
||||||
stripe_payment_intent_id="pi_other",
|
stripe_payment_intent_id="pi_other",
|
||||||
amount_cents=100, granted_writs=144,
|
amount_cents=100, granted_writs=12,
|
||||||
)
|
)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk},
|
"/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk},
|
||||||
@@ -234,7 +235,7 @@ class StripeWebhookViewTest(TestCase):
|
|||||||
self.purchase = Purchase.objects.create(
|
self.purchase = Purchase.objects.create(
|
||||||
user=self.user, shop_item=self.tithe,
|
user=self.user, shop_item=self.tithe,
|
||||||
stripe_payment_intent_id="pi_wh_1",
|
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")
|
@mock.patch("apps.dashboard.views.stripe")
|
||||||
|
|||||||
@@ -106,6 +106,37 @@ class WalletTokensAppletAllTrinketsVisibleTest(TestCase):
|
|||||||
[_] = parsed.cssselect("#id_carte_token")
|
[_] = 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):
|
class WalletPassTokenVisibilityTest(TestCase):
|
||||||
"""PASS is admin-only — the model guard blocks bogus rows from existing
|
"""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
|
for non-staff users, but defend the wallet surface too so a future
|
||||||
|
|||||||
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)
|
slug = models.SlugField(unique=True)
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
description = models.TextField(blank=True, default="")
|
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")
|
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
|
badge_text = models.CharField(max_length=8, blank=True, default="") # eg "×5"; "" = no badge
|
||||||
price_cents = models.PositiveIntegerField()
|
price_cents = models.PositiveIntegerField()
|
||||||
@@ -383,6 +387,14 @@ class ShopItem(models.Model):
|
|||||||
return f"${int(dollars)}"
|
return f"${int(dollars)}"
|
||||||
return f"${dollars:.2f}"
|
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):
|
class Purchase(models.Model):
|
||||||
"""Audit-trail row for one shop transaction. Created at PENDING on
|
"""Audit-trail row for one shop transaction. Created at PENDING on
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ class ShopItemModelTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(str(item), "Wristband")
|
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):
|
def test_is_available_for_unlimited_item(self):
|
||||||
"""`max_owned=None` → item is always available."""
|
"""`max_owned=None` → item is always available."""
|
||||||
item = ShopItem.objects.create(
|
item = ShopItem.objects.create(
|
||||||
@@ -221,14 +240,17 @@ class SeededShopCatalogTest(TestCase):
|
|||||||
self.assertEqual(item.price_cents, 100)
|
self.assertEqual(item.price_cents, 100)
|
||||||
self.assertEqual(item.granted_token_type, Token.TITHE)
|
self.assertEqual(item.granted_token_type, Token.TITHE)
|
||||||
self.assertEqual(item.granted_count, 1)
|
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)
|
self.assertIsNone(item.max_owned)
|
||||||
|
|
||||||
def test_tithe_five_bundle_item_present(self):
|
def test_tithe_five_bundle_item_present(self):
|
||||||
item = ShopItem.objects.get(slug="tithe-5")
|
item = ShopItem.objects.get(slug="tithe-5")
|
||||||
self.assertEqual(item.price_cents, 400)
|
self.assertEqual(item.price_cents, 400)
|
||||||
self.assertEqual(item.granted_count, 5)
|
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")
|
self.assertEqual(item.badge_text, "×5")
|
||||||
|
|
||||||
def test_band_item_present(self):
|
def test_band_item_present(self):
|
||||||
@@ -238,6 +260,16 @@ class SeededShopCatalogTest(TestCase):
|
|||||||
self.assertEqual(item.granted_count, 1)
|
self.assertEqual(item.granted_count, 1)
|
||||||
self.assertEqual(item.max_owned, 1)
|
self.assertEqual(item.max_owned, 1)
|
||||||
self.assertEqual(item.granted_writs, 0)
|
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):
|
def test_all_three_items_active(self):
|
||||||
for slug in ("tithe-1", "tithe-5", "band-1"):
|
for slug in ("tithe-1", "tithe-5", "band-1"):
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ class WalletDisplayTest(FunctionalTest):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
|
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows, order in [
|
||||||
("wallet-balances", "Wallet Balances", 3, 3),
|
("wallet-shop", "Shop", 12, 3, 10),
|
||||||
("wallet-tokens", "Wallet Tokens", 3, 3),
|
("wallet-balances", "Wallet Balances", 3, 3, 100),
|
||||||
("wallet-payment", "Payment Methods", 6, 3),
|
("wallet-tokens", "Wallet Tokens", 3, 3, 100),
|
||||||
("wallet-shop", "Shop", 12, 3),
|
("wallet-payment", "Payment Methods", 6, 3, 100),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.update_or_create(
|
||||||
slug=slug,
|
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`
|
# Seed the 3 starting ShopItems — migration `lyric/0009_seed_shop_items`
|
||||||
# populates these in fresh DBs, but FTs run on `TransactionTestCase`
|
# populates these in fresh DBs, but FTs run on `TransactionTestCase`
|
||||||
@@ -28,20 +31,20 @@ class WalletDisplayTest(FunctionalTest):
|
|||||||
ShopItem.objects.update_or_create(
|
ShopItem.objects.update_or_create(
|
||||||
slug="tithe-1",
|
slug="tithe-1",
|
||||||
defaults={
|
defaults={
|
||||||
"name": "Tithe Token", "description": "1 Tithe + 144 Writs",
|
"name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
|
||||||
"icon": "fa-piggy-bank", "badge_text": "",
|
"icon": "fa-piggy-bank", "badge_text": "",
|
||||||
"price_cents": 100, "granted_token_type": Token.TITHE,
|
"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,
|
"max_owned": None, "display_order": 10, "active": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ShopItem.objects.update_or_create(
|
ShopItem.objects.update_or_create(
|
||||||
slug="tithe-5",
|
slug="tithe-5",
|
||||||
defaults={
|
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",
|
"icon": "fa-piggy-bank", "badge_text": "×5",
|
||||||
"price_cents": 400, "granted_token_type": Token.TITHE,
|
"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,
|
"max_owned": None, "display_order": 20, "active": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -49,6 +52,7 @@ class WalletDisplayTest(FunctionalTest):
|
|||||||
slug="band-1",
|
slug="band-1",
|
||||||
defaults={
|
defaults={
|
||||||
"name": "Wristband", "description": "Admit All Entry",
|
"name": "Wristband", "description": "Admit All Entry",
|
||||||
|
"shoptalk": "Unlimited free entry (BYOB)",
|
||||||
"icon": "fa-ring", "badge_text": "",
|
"icon": "fa-ring", "badge_text": "",
|
||||||
"price_cents": 2000, "granted_token_type": Token.BAND,
|
"price_cents": 2000, "granted_token_type": Token.BAND,
|
||||||
"granted_count": 1, "granted_writs": 0,
|
"granted_count": 1, "granted_writs": 0,
|
||||||
@@ -325,6 +329,77 @@ class WalletDisplayTest(FunctionalTest):
|
|||||||
lambda: self.assertNotIn("active", portal.get_attribute("class"))
|
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):
|
def test_shop_band_already_owned_shows_disabled_buy_btn(self):
|
||||||
"""User who already owns 1 BAND (`max_owned=1`) sees the band-1
|
"""User who already owns 1 BAND (`max_owned=1`) sees the band-1
|
||||||
tile w. its BUY btn disabled + an 'Already owned' microtooltip
|
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.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
|
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")
|
band_tile = self.browser.find_element(By.ID, "id_shop_band-1")
|
||||||
# BUY btn rendered as `.btn-disabled` w. × glyph (parity w. game-
|
# Capped item — NO buy btn at all (parity w. Game Kit's status-
|
||||||
# kit's disabled DON/DOFF buttons). Read via `textContent` because
|
# only "Equipped" / "In-Use: X" pills, which never pair status
|
||||||
# `.tt` is `display: none` by default + Selenium's `.text` returns
|
# text w. a disabled action btn).
|
||||||
# empty for hidden subtrees.
|
self.assertEqual(band_tile.find_elements(By.CSS_SELECTOR, ".tt-buy-btn"), [])
|
||||||
buy_btn = band_tile.find_element(By.CSS_SELECTOR, ".tt-buy-btn")
|
# 'Already owned' microtext lives in the `.tt-micro` sibling-of-.tt
|
||||||
self.assertIn("btn-disabled", buy_btn.get_attribute("class"))
|
# (cloned into `#id_mini_tooltip_portal` on hover by wallet.js).
|
||||||
self.assertEqual(buy_btn.get_attribute("textContent").strip(), "×")
|
micro_html = band_tile.find_element(By.CSS_SELECTOR, ".tt-micro").get_attribute("innerHTML")
|
||||||
# Microtooltip swap signals why it's disabled
|
self.assertIn("Already owned", micro_html)
|
||||||
tt_html = band_tile.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
|
|
||||||
self.assertIn("Already owned", tt_html)
|
|
||||||
|
|
||||||
|
|
||||||
# Legacy `test_user_can_purchase_tithe_token_bundle` FT (asserting
|
# Legacy `test_user_can_purchase_tithe_token_bundle` FT (asserting
|
||||||
|
|||||||
@@ -32,36 +32,28 @@ function _seedShopFixture() {
|
|||||||
root.dataset.defaultPaymentMethodId = 'pm_test_4242';
|
root.dataset.defaultPaymentMethodId = 'pm_test_4242';
|
||||||
root.dataset.stripePublishableKey = 'pk_test_fixture';
|
root.dataset.stripePublishableKey = 'pk_test_fixture';
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div id="id_shop_tithe-1"
|
<div id="id_shop_tithe-1" class="shop-tile">
|
||||||
class="shop-tile"
|
|
||||||
data-shop-item-slug="tithe-1"
|
|
||||||
data-price-cents="100"
|
|
||||||
data-item-name="Tithe Token">
|
|
||||||
<i class="fa-solid fa-piggy-bank"></i>
|
<i class="fa-solid fa-piggy-bank"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
<h4 class="tt-title">Tithe Token</h4>
|
<h4 class="tt-title">Tithe Token</h4>
|
||||||
<p class="tt-description">1 Tithe + 144 Writs</p>
|
<p class="tt-description">1 Tithe + 144 Writs</p>
|
||||||
<p class="tt-price">$1</p>
|
<p class="tt-price">$1</p>
|
||||||
<div class="tt-microbutton-portal">
|
</div>
|
||||||
|
<div class="tt-micro">
|
||||||
<button class="btn btn-primary tt-buy-btn"
|
<button class="btn btn-primary tt-buy-btn"
|
||||||
data-shop-item-slug="tithe-1">BUY ITEM</button>
|
data-shop-item-slug="tithe-1"
|
||||||
|
data-item-name="Tithe Token"
|
||||||
|
data-price-cents="100">BUY ITEM</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="id_shop_band-1" class="shop-tile">
|
||||||
<div id="id_shop_band-1"
|
|
||||||
class="shop-tile"
|
|
||||||
data-shop-item-slug="band-1"
|
|
||||||
data-price-cents="2000"
|
|
||||||
data-item-name="Wristband">
|
|
||||||
<i class="fa-solid fa-ring"></i>
|
<i class="fa-solid fa-ring"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
<h4 class="tt-title">Wristband</h4>
|
<h4 class="tt-title">Wristband</h4>
|
||||||
<p class="tt-price">$20</p>
|
<p class="tt-price">$20</p>
|
||||||
<div class="tt-microbutton-portal">
|
|
||||||
<button class="btn btn-primary tt-buy-btn btn-disabled"
|
|
||||||
data-shop-item-slug="band-1">×</button>
|
|
||||||
<p class="tt-already-owned"><em>Already owned</em></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tt-micro">
|
||||||
|
<span class="tt-already-owned">Already owned</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -75,6 +67,7 @@ describe('WalletShop.initWalletShop', () => {
|
|||||||
let _origFetch;
|
let _origFetch;
|
||||||
let _origStripe;
|
let _origStripe;
|
||||||
let _origShowGuard;
|
let _origShowGuard;
|
||||||
|
let _origWalletTooltips;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = _seedShopFixture();
|
fixture = _seedShopFixture();
|
||||||
@@ -93,6 +86,13 @@ describe('WalletShop.initWalletShop', () => {
|
|||||||
});
|
});
|
||||||
_origShowGuard = window.showGuard;
|
_origShowGuard = window.showGuard;
|
||||||
window.showGuard = jasmine.createSpy('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(() => {
|
afterEach(() => {
|
||||||
@@ -100,6 +100,7 @@ describe('WalletShop.initWalletShop', () => {
|
|||||||
window.fetch = _origFetch;
|
window.fetch = _origFetch;
|
||||||
window.Stripe = _origStripe;
|
window.Stripe = _origStripe;
|
||||||
window.showGuard = _origShowGuard;
|
window.showGuard = _origShowGuard;
|
||||||
|
window.WalletTooltips = _origWalletTooltips;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── T1 ── click on enabled BUY opens guard portal w. price prompt ───────
|
// ── 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
|
expect(typeof args[2]).toBe('function'); // onConfirm callback
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── T2 ── click on disabled BUY does NOT open guard ─────────────────────
|
// ── T2 ── capped items render no BUY btn at all (status-only microtext)
|
||||||
it('T2: clicking the .btn-disabled BUY (band-1, already-owned) is a no-op', () => {
|
it('T2: capped band-1 tile renders no BUY btn (only the "Already owned" pill)', () => {
|
||||||
WalletShop.initWalletShop();
|
WalletShop.initWalletShop();
|
||||||
const btn = fixture.querySelector('#id_shop_band-1 .tt-buy-btn');
|
// No `.tt-buy-btn` inside the band tile — capped items follow the
|
||||||
btn.click();
|
// Game Kit "Equipped" / "In-Use: X" pattern: status text only, no
|
||||||
expect(window.showGuard).not.toHaveBeenCalled();
|
// 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 ───────
|
// ── T3 ── onConfirm callback POSTs to /shop/buy w. the right slug ───────
|
||||||
@@ -158,4 +165,39 @@ describe('WalletShop.initWalletShop', () => {
|
|||||||
fixture.remove();
|
fixture.remove();
|
||||||
expect(() => WalletShop.initWalletShop()).not.toThrow();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,45 +105,72 @@
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ×5 badge — top-right corner, --quaUser glyph on --quiUser bg, 2rem circle.
|
// ×N quantity badge — top-right corner, --quaUser glyph on --quiUser bg.
|
||||||
// Per-locked spec from [[project-wallet-shop-expansion]].
|
// 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 {
|
.shop-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -0.5rem;
|
top: -0.8rem;
|
||||||
right: -0.75rem;
|
right: -1.2rem;
|
||||||
width: 2rem;
|
width: 1.5rem;
|
||||||
height: 2rem;
|
height: 1.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(var(--quiUser), 1);
|
background: rgba(var(--secUser), 1);
|
||||||
color: rgba(var(--quaUser), 1);
|
color: rgba(var(--priUser), 1);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 900;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Microtooltip — the buy-btn lives inside the main tooltip portal, styled
|
// `.tt-micro` — sibling of `.tt` on each `.shop-tile`. Holds the BUY-ITEM
|
||||||
// like Game Kit's `#id_mini_tooltip_portal` (Equipped/Unequipped/In-Use).
|
// btn (or × + 'Already owned' when capped). wallet.js's tooltip handler
|
||||||
// Hover persistence (cursor moves from tile → portal → microbutton without
|
// clones this into `#id_mini_tooltip_portal` on hover so the btn appears
|
||||||
// dismissing the tooltip) is handled by `wallet-shop.js`.
|
// as a small floating bubble adjacent to the main tooltip card —
|
||||||
.tt-microbutton-portal {
|
// mirroring Game Kit's Equipped/Unequipped/In-Use microtooltip pattern.
|
||||||
margin-top: 0.5rem;
|
// Hidden in the source DOM; only the portal clone is visible.
|
||||||
display: flex;
|
.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;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
|
||||||
.tt-buy-btn {
|
.tt-buy-btn {
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.25rem 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 {
|
.tt-already-owned {
|
||||||
font-size: 0.7rem;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgba(var(--terUser), 0.85);
|
font-style: italic;
|
||||||
}
|
color: rgba(var(--secUser), 1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active { display: flex; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,36 +32,28 @@ function _seedShopFixture() {
|
|||||||
root.dataset.defaultPaymentMethodId = 'pm_test_4242';
|
root.dataset.defaultPaymentMethodId = 'pm_test_4242';
|
||||||
root.dataset.stripePublishableKey = 'pk_test_fixture';
|
root.dataset.stripePublishableKey = 'pk_test_fixture';
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div id="id_shop_tithe-1"
|
<div id="id_shop_tithe-1" class="shop-tile">
|
||||||
class="shop-tile"
|
|
||||||
data-shop-item-slug="tithe-1"
|
|
||||||
data-price-cents="100"
|
|
||||||
data-item-name="Tithe Token">
|
|
||||||
<i class="fa-solid fa-piggy-bank"></i>
|
<i class="fa-solid fa-piggy-bank"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
<h4 class="tt-title">Tithe Token</h4>
|
<h4 class="tt-title">Tithe Token</h4>
|
||||||
<p class="tt-description">1 Tithe + 144 Writs</p>
|
<p class="tt-description">1 Tithe + 144 Writs</p>
|
||||||
<p class="tt-price">$1</p>
|
<p class="tt-price">$1</p>
|
||||||
<div class="tt-microbutton-portal">
|
</div>
|
||||||
|
<div class="tt-micro">
|
||||||
<button class="btn btn-primary tt-buy-btn"
|
<button class="btn btn-primary tt-buy-btn"
|
||||||
data-shop-item-slug="tithe-1">BUY ITEM</button>
|
data-shop-item-slug="tithe-1"
|
||||||
|
data-item-name="Tithe Token"
|
||||||
|
data-price-cents="100">BUY ITEM</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="id_shop_band-1" class="shop-tile">
|
||||||
<div id="id_shop_band-1"
|
|
||||||
class="shop-tile"
|
|
||||||
data-shop-item-slug="band-1"
|
|
||||||
data-price-cents="2000"
|
|
||||||
data-item-name="Wristband">
|
|
||||||
<i class="fa-solid fa-ring"></i>
|
<i class="fa-solid fa-ring"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
<h4 class="tt-title">Wristband</h4>
|
<h4 class="tt-title">Wristband</h4>
|
||||||
<p class="tt-price">$20</p>
|
<p class="tt-price">$20</p>
|
||||||
<div class="tt-microbutton-portal">
|
|
||||||
<button class="btn btn-primary tt-buy-btn btn-disabled"
|
|
||||||
data-shop-item-slug="band-1">×</button>
|
|
||||||
<p class="tt-already-owned"><em>Already owned</em></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tt-micro">
|
||||||
|
<span class="tt-already-owned">Already owned</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -75,6 +67,7 @@ describe('WalletShop.initWalletShop', () => {
|
|||||||
let _origFetch;
|
let _origFetch;
|
||||||
let _origStripe;
|
let _origStripe;
|
||||||
let _origShowGuard;
|
let _origShowGuard;
|
||||||
|
let _origWalletTooltips;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = _seedShopFixture();
|
fixture = _seedShopFixture();
|
||||||
@@ -93,6 +86,13 @@ describe('WalletShop.initWalletShop', () => {
|
|||||||
});
|
});
|
||||||
_origShowGuard = window.showGuard;
|
_origShowGuard = window.showGuard;
|
||||||
window.showGuard = jasmine.createSpy('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(() => {
|
afterEach(() => {
|
||||||
@@ -100,6 +100,7 @@ describe('WalletShop.initWalletShop', () => {
|
|||||||
window.fetch = _origFetch;
|
window.fetch = _origFetch;
|
||||||
window.Stripe = _origStripe;
|
window.Stripe = _origStripe;
|
||||||
window.showGuard = _origShowGuard;
|
window.showGuard = _origShowGuard;
|
||||||
|
window.WalletTooltips = _origWalletTooltips;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── T1 ── click on enabled BUY opens guard portal w. price prompt ───────
|
// ── 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
|
expect(typeof args[2]).toBe('function'); // onConfirm callback
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── T2 ── click on disabled BUY does NOT open guard ─────────────────────
|
// ── T2 ── capped items render no BUY btn at all (status-only microtext)
|
||||||
it('T2: clicking the .btn-disabled BUY (band-1, already-owned) is a no-op', () => {
|
it('T2: capped band-1 tile renders no BUY btn (only the "Already owned" pill)', () => {
|
||||||
WalletShop.initWalletShop();
|
WalletShop.initWalletShop();
|
||||||
const btn = fixture.querySelector('#id_shop_band-1 .tt-buy-btn');
|
// No `.tt-buy-btn` inside the band tile — capped items follow the
|
||||||
btn.click();
|
// Game Kit "Equipped" / "In-Use: X" pattern: status text only, no
|
||||||
expect(window.showGuard).not.toHaveBeenCalled();
|
// 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 ───────
|
// ── T3 ── onConfirm callback POSTs to /shop/buy w. the right slug ───────
|
||||||
@@ -158,4 +165,39 @@ describe('WalletShop.initWalletShop', () => {
|
|||||||
fixture.remove();
|
fixture.remove();
|
||||||
expect(() => WalletShop.initWalletShop()).not.toThrow();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
{% include "apps/wallet/_partials/_applets.html" %}
|
{% include "apps/wallet/_partials/_applets.html" %}
|
||||||
</div>
|
</div>
|
||||||
<div id="id_tooltip_portal" class="token-tooltip"></div>
|
<div id="id_tooltip_portal" class="token-tooltip"></div>
|
||||||
|
{# Microtooltip for the Shop applet's BUY-ITEM btn / 'Already owned' note. #}
|
||||||
|
{# Mirrors gameboard.html's mini portal (Equipped/Unequipped/In-Use). #}
|
||||||
|
<div id="id_mini_tooltip_portal" class="token-tooltip token-tooltip--mini"></div>
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
<script src="{% static "apps/dashboard/wallet.js" %}"></script>
|
<script src="{% static "apps/dashboard/wallet.js" %}"></script>
|
||||||
<script src="{% static "apps/dashboard/wallet-shop.js" %}"></script>
|
<script src="{% static "apps/dashboard/wallet-shop.js" %}"></script>
|
||||||
|
|||||||
@@ -20,28 +20,41 @@
|
|||||||
<span class="shop-badge">{{ item.badge_text }}</span>
|
<span class="shop-badge">{{ item.badge_text }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
|
{# 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.#}
|
||||||
<h4 class="tt-title">{{ item.name }}</h4>
|
<h4 class="tt-title">{{ item.name }}</h4>
|
||||||
<p class="tt-description">{{ item.description }}</p>
|
<p class="tt-description">{{ item.description }}</p>
|
||||||
|
{% if item.shoptalk %}
|
||||||
|
<p class="tt-shoptalk"><em>{{ item.shoptalk }}</em></p>
|
||||||
|
{% endif %}
|
||||||
<p class="tt-price">{{ item.price_display }}</p>
|
<p class="tt-price">{{ item.price_display }}</p>
|
||||||
<div class="tt-microbutton-portal">
|
<p class="tt-expiry">{{ item.tooltip_expiry }}</p>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
<div class="tt-micro">
|
||||||
{% if item.available %}
|
{% if item.available %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary tt-buy-btn"
|
class="btn btn-primary tt-buy-btn"
|
||||||
data-shop-item-slug="{{ item.slug }}"
|
data-shop-item-slug="{{ item.slug }}"
|
||||||
|
data-item-name="{{ item.name }}"
|
||||||
|
data-price-cents="{{ item.price_cents }}"
|
||||||
>BUY ITEM</button>
|
>BUY ITEM</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button
|
{# Capped item — no BUY btn at all (parity w. Game Kit's #}
|
||||||
type="button"
|
{# 'In-Use: X' / 'Equipped' microtext pattern, which never #}
|
||||||
class="btn btn-primary tt-buy-btn btn-disabled"
|
{# pairs status text w. a disabled action btn). #}
|
||||||
data-shop-item-slug="{{ item.slug }}"
|
<span class="tt-already-owned">Already owned</span>
|
||||||
aria-disabled="true"
|
|
||||||
>×</button>
|
|
||||||
<p class="tt-already-owned"><em>Already owned</em></p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user