From 86a349b64eb4bd9064e256359b3edbf1346b05a5 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 30 May 2026 14:51:21 -0400 Subject: [PATCH] =?UTF-8?q?wallet=20shop:=20free=20($0)=20RWS=20+=20Fioren?= =?UTF-8?q?tine=20decks=20=E2=80=94=20FREE=20ITEM=20claim=20unlocks=20to?= =?UTF-8?q?=20Game=20Kit=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model: DeckVariant.free_in_shop flag (0015 schema); data migration 0016 seeds RWS + Minchiate Fiorentine True (Earthman stays False — it's auto- granted at signup, not shopped) - view: _free_decks_for decorates the free-in-shop catalog w. a per-user .owned flag; shop_claim_free POST endpoint adds the deck to unlocked_decks (idempotent M2M add) — the free_in_shop filter is the guard that stops the $0 endpoint unlocking paid/auto-granted decks (404 otherwise). free_decks wired into both the wallet view + toggle_wallet_applets HX context - url: wallet/shop/claim (action, no trailing slash) - template: free-deck tiles reuse the deck's own Game Kit tooltip prose (name / card-count / description / stock-version line) + a $0 .tt-price pinned top-right like paid tiles; .tt-micro carries .tt-free-btn (FREE ITEM) or the same .tt-already-owned pill once owned; reuses _deck_stack_icon.html - js: wallet-shop.js _onFreeClick → _doClaimFree POSTs deck_slug → reload (server-rendered owned pill, same posture as the BUY reload). No guard portal — free = one-click. Rides the SAME delegated roots as BUY + idempotent wiring - css: FREE ITEM wraps to 2 lines like BUY ITEM (extend the mini-portal .tt-buy-btn white-space:normal rule to .tt-free-btn); shop deck tiles get the Game Kit fan-out on hover/active by adding .shop-tile-deck to the .deck-stack-icon splay trigger list — DRY, no transform duplication - tests: 8 ITs (shop_claim_free behaviors + free_decks context owned flag); FT claims RWS → 'Already owned' swap → id_kit_tarot_deck appears in Game Kit; 3 Jasmine specs F1-F3 (claim POST / no-guard / idempotent wiring); 679 dashboard+epic green, no regressions - trap: hover-hidden microtooltip btn → .text is '' under Selenium; read get_attribute('textContent') instead [[feedback-selenium-opacity-zero]] Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) --- .../static/apps/dashboard/wallet-shop.js | 46 +++++- .../tests/integrated/test_shop_views.py | 134 ++++++++++++++++++ src/apps/dashboard/urls.py | 1 + src/apps/dashboard/views.py | 35 +++++ .../0015_deckvariant_free_in_shop.py | 18 +++ .../0016_seed_free_in_shop_decks.py | 31 ++++ src/apps/epic/models.py | 5 + src/functional_tests/test_dash_wallet.py | 75 ++++++++++ src/static/tests/WalletShopSpec.js | 48 +++++++ src/static_src/scss/_gameboard.scss | 10 +- src/static_src/scss/_wallet-tokens.scss | 12 +- src/static_src/tests/WalletShopSpec.js | 48 +++++++ .../wallet/_partials/_applet-wallet-shop.html | 39 +++++ 13 files changed, 495 insertions(+), 7 deletions(-) create mode 100644 src/apps/epic/migrations/0015_deckvariant_free_in_shop.py create mode 100644 src/apps/epic/migrations/0016_seed_free_in_shop_decks.py diff --git a/src/apps/dashboard/static/apps/dashboard/wallet-shop.js b/src/apps/dashboard/static/apps/dashboard/wallet-shop.js index 767e302..72c7cc4 100644 --- a/src/apps/dashboard/static/apps/dashboard/wallet-shop.js +++ b/src/apps/dashboard/static/apps/dashboard/wallet-shop.js @@ -76,6 +76,38 @@ const WalletShop = (function () { } } + async function _doClaimFree(slug) { + // Free decks (RWS / Fiorentine) — $0, no Stripe, no guard portal. + // One click POSTs the claim; the server adds the DeckVariant to + // `unlocked_decks` (→ Game Kit on /gameboard/). + const res = await fetch('/dashboard/wallet/shop/claim', { + method: 'POST', + headers: { + 'X-CSRFToken': _getCsrf(), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'deck_slug=' + encodeURIComponent(slug), + }); + if (!res.ok) { + alert('Could not claim deck (' + res.status + ').'); + return; + } + // Reload so the tile re-renders w. the 'Already owned' pill — server + // is the source of truth (same posture as the BUY flow's reload). + window.location.reload(); + } + + function _onFreeClick(e) { + const btn = e.target.closest('.tt-free-btn'); + if (!btn) return; + if (btn.classList.contains('btn-disabled')) return; + e.preventDefault(); + e.stopPropagation(); + const slug = btn.dataset.deckSlug; + if (!slug) return; + _doClaimFree(slug); + } + function _onBuyClick(e) { const btn = e.target.closest('.tt-buy-btn'); if (!btn) return; @@ -139,11 +171,21 @@ const WalletShop = (function () { // (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. + // Free-deck claim (`.tt-free-btn`) rides the SAME delegated roots as + // the BUY flow — the FREE ITEM btn lives in `.tt-micro` (clones into + // the mini portal on hover, same as BUY ITEM). shopRoot.addEventListener('click', _onBuyClick); + shopRoot.addEventListener('click', _onFreeClick); const portal = document.getElementById('id_tooltip_portal'); - if (portal) portal.addEventListener('click', _onBuyClick); + if (portal) { + portal.addEventListener('click', _onBuyClick); + portal.addEventListener('click', _onFreeClick); + } const miniPortal = document.getElementById('id_mini_tooltip_portal'); - if (miniPortal) miniPortal.addEventListener('click', _onBuyClick); + if (miniPortal) { + miniPortal.addEventListener('click', _onBuyClick); + miniPortal.addEventListener('click', _onFreeClick); + } shopRoot.dataset.shopWired = '1'; } diff --git a/src/apps/dashboard/tests/integrated/test_shop_views.py b/src/apps/dashboard/tests/integrated/test_shop_views.py index a4e63bd..8e683c5 100644 --- a/src/apps/dashboard/tests/integrated/test_shop_views.py +++ b/src/apps/dashboard/tests/integrated/test_shop_views.py @@ -21,9 +21,143 @@ from unittest import mock from django.test import TestCase, override_settings +from apps.epic.models import DeckVariant from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User +def _seed_free_decks(): + """Two free-in-shop decks (RWS + Fiorentine) + one paid deck (Earthman, + free_in_shop=False) so the claim endpoint's `free_in_shop` guard is + exercised. Mirrors the seed-migration row shapes (TestCase rolls back + the data migrations).""" + rws, _ = DeckVariant.objects.update_or_create( + slug="tarot-rider-waite-smith", + defaults={ + "name": "Tarot (Rider-Waite-Smith)", "card_count": 78, + "family": "english", "has_card_images": False, + "is_polarized": False, "free_in_shop": True, + }, + ) + fiorentine, _ = DeckVariant.objects.update_or_create( + slug="minchiate-fiorentine-1860-1890", + defaults={ + "name": "Minchiate Fiorentine (1860–1890)", "card_count": 97, + "family": "italian", "has_card_images": True, + "is_polarized": False, "free_in_shop": True, + "description": "97-card Minchiate Fiorentine deck.", + }, + ) + earthman, _ = DeckVariant.objects.update_or_create( + slug="earthman", + defaults={ + "name": "Earthman", "card_count": 106, "is_default": True, + "is_polarized": True, "has_card_images": False, + "free_in_shop": False, + }, + ) + return rws, fiorentine, earthman + + +class ShopClaimFreeViewTest(TestCase): + """`POST /dashboard/wallet/shop/claim` (`shop_claim_free`) — adds a free- + in-shop DeckVariant to the user's `unlocked_decks` so it appears in the + Game Kit applet. No Stripe, no Purchase row, idempotent.""" + + def setUp(self): + self.rws, self.fiorentine, self.earthman = _seed_free_decks() + self.user = User.objects.create(email="decker@test.io") + # Start from a known unlock set — strip the signal's auto-grant so + # the claim is the only thing that adds a deck. + self.user.unlocked_decks.clear() + self.client.force_login(self.user) + + def test_requires_login(self): + self.client.logout() + response = self.client.post( + "/dashboard/wallet/shop/claim", + {"deck_slug": "tarot-rider-waite-smith"}, + ) + self.assertRedirects( + response, "/?next=/dashboard/wallet/shop/claim", + fetch_redirect_response=False, + ) + + def test_claim_adds_deck_to_unlocked(self): + response = self.client.post( + "/dashboard/wallet/shop/claim", + {"deck_slug": "tarot-rider-waite-smith"}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue( + self.user.unlocked_decks.filter(pk=self.rws.pk).exists() + ) + + def test_claim_returns_owned_json(self): + response = self.client.post( + "/dashboard/wallet/shop/claim", + {"deck_slug": "tarot-rider-waite-smith"}, + ) + body = response.json() + self.assertTrue(body["owned"]) + self.assertEqual(body["deck_name"], "Tarot (Rider-Waite-Smith)") + + def test_claim_is_idempotent(self): + for _ in range(2): + response = self.client.post( + "/dashboard/wallet/shop/claim", + {"deck_slug": "tarot-rider-waite-smith"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + self.user.unlocked_decks.filter(pk=self.rws.pk).count(), 1 + ) + + def test_unknown_slug_returns_404(self): + response = self.client.post( + "/dashboard/wallet/shop/claim", {"deck_slug": "no-such-deck"}, + ) + self.assertEqual(response.status_code, 404) + + def test_non_free_deck_cannot_be_claimed(self): + """Guard: a deck with free_in_shop=False (eg Earthman, or any paid + deck) is NOT claimable via this $0 endpoint → 404, no unlock.""" + response = self.client.post( + "/dashboard/wallet/shop/claim", {"deck_slug": "earthman"}, + ) + self.assertEqual(response.status_code, 404) + self.assertFalse( + self.user.unlocked_decks.filter(pk=self.earthman.pk).exists() + ) + + +class WalletFreeDecksContextTest(TestCase): + """The wallet page exposes `free_decks` — the free-in-shop DeckVariants + decorated w. a per-user `.owned` flag the Shop applet uses to render + FREE ITEM vs 'Already owned'.""" + + def setUp(self): + self.rws, self.fiorentine, self.earthman = _seed_free_decks() + self.user = User.objects.create(email="ctx@test.io") + self.user.unlocked_decks.clear() + self.client.force_login(self.user) + + def test_free_decks_in_context_unowned_initially(self): + response = self.client.get("/dashboard/wallet/") + free = {d.slug: d for d in response.context["free_decks"]} + # Both free decks present; the paid Earthman is NOT listed here. + self.assertIn("tarot-rider-waite-smith", free) + self.assertIn("minchiate-fiorentine-1860-1890", free) + self.assertNotIn("earthman", free) + self.assertFalse(free["tarot-rider-waite-smith"].owned) + + def test_free_deck_marked_owned_after_claim(self): + self.user.unlocked_decks.add(self.rws) + response = self.client.get("/dashboard/wallet/") + free = {d.slug: d for d in response.context["free_decks"]} + self.assertTrue(free["tarot-rider-waite-smith"].owned) + self.assertFalse(free["minchiate-fiorentine-1860-1890"].owned) + + def _seed_starting_items(): """Mirror the seed-migration row shape so each TestCase starts w. a known catalog (TestCase rolls back the data migration, so the rows diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py index fa58107..a843474 100644 --- a/src/apps/dashboard/urls.py +++ b/src/apps/dashboard/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'), path('wallet/shop/buy', views.shop_buy, name='shop_buy'), path('wallet/shop/confirm', views.shop_confirm, name='shop_confirm'), + path('wallet/shop/claim', views.shop_claim_free, name='shop_claim_free'), path('kit-bag/', views.kit_bag, name='kit_bag'), path('sky/', views.sky_view, name='sky'), path('sky/preview', views.sky_preview, name='sky_preview'), diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 1edbdb9..c14ae7b 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -17,6 +17,7 @@ from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from apps.applets.utils import applet_context, apply_applet_toggle from apps.drama.models import Note +from apps.epic.models import DeckVariant from apps.epic.utils import _compute_distinctions from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username @@ -188,6 +189,18 @@ def _shop_items_for(user): return items +def _free_decks_for(user): + """Decorate the free-in-shop DeckVariant catalog (RWS + Fiorentine) w. a + per-user `.owned` flag so the Shop applet renders a FREE ITEM btn for + decks the user hasn't claimed + an 'Already owned' pill for ones already + in `unlocked_decks`. Ordered by name for a stable render.""" + unlocked_ids = set(user.unlocked_decks.values_list("pk", flat=True)) + decks = list(DeckVariant.objects.filter(free_in_shop=True).order_by("name")) + for deck in decks: + deck.owned = deck.pk in unlocked_ids + return decks + + @login_required(login_url="/") @ensure_csrf_cookie def wallet(request): @@ -204,6 +217,7 @@ def wallet(request): "coin": request.user.tokens.filter(token_type=Token.COIN).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "shop_items": shop_items, + "free_decks": _free_decks_for(request.user), "default_payment_method_id": default_pm.stripe_pm_id if default_pm else "", "stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY, "free_tokens": free_tokens, @@ -246,6 +260,7 @@ def toggle_wallet_applets(request): "coin": request.user.tokens.filter(token_type=Token.COIN).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "shop_items": _shop_items_for(request.user), + "free_decks": _free_decks_for(request.user), "default_payment_method_id": default_pm.stripe_pm_id if default_pm else "", "stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY, "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), @@ -382,6 +397,26 @@ def shop_confirm(request): return JsonResponse({"status": purchase.status}) +@login_required(login_url="/") +def shop_claim_free(request): + """Claim a free ($0) deck from the Shop applet — adds the DeckVariant to + the user's `unlocked_decks` so it renders in the Game Kit applet. No + Stripe, no Purchase row (free decks aren't paid goods). + + Body: `deck_slug` (form-encoded). + Returns: 200 `{owned: true, deck_name}` on claim or re-claim (M2M add is + idempotent); 404 if the slug isn't a `free_in_shop` deck — the + `free_in_shop` filter is the guard that stops this $0 endpoint + from unlocking paid/auto-granted decks (eg Earthman). + """ + slug = request.POST.get("deck_slug", "") + deck = DeckVariant.objects.filter(slug=slug, free_in_shop=True).first() + if deck is None: + return HttpResponse(status=404) + request.user.unlocked_decks.add(deck) + return JsonResponse({"owned": True, "deck_name": deck.name}) + + @csrf_exempt def stripe_webhook(request): """Stripe webhook listener. Verifies signature against diff --git a/src/apps/epic/migrations/0015_deckvariant_free_in_shop.py b/src/apps/epic/migrations/0015_deckvariant_free_in_shop.py new file mode 100644 index 0000000..4f5c133 --- /dev/null +++ b/src/apps/epic/migrations/0015_deckvariant_free_in_shop.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-30 18:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0014_rws_has_card_images_true'), + ] + + operations = [ + migrations.AddField( + model_name='deckvariant', + name='free_in_shop', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/apps/epic/migrations/0016_seed_free_in_shop_decks.py b/src/apps/epic/migrations/0016_seed_free_in_shop_decks.py new file mode 100644 index 0000000..525f681 --- /dev/null +++ b/src/apps/epic/migrations/0016_seed_free_in_shop_decks.py @@ -0,0 +1,31 @@ +"""Mark RWS + Minchiate Fiorentine as free-in-shop ($0) decks. + +These two stock decks are offered free in the wallet Shop applet — a one- +click claim adds them to a user's `unlocked_decks` (no Stripe). Earthman is +deliberately left False: it's auto-granted at signup, not shopped. +""" +from django.db import migrations + + +FREE_SLUGS = ("tarot-rider-waite-smith", "minchiate-fiorentine-1860-1890") + + +def set_free_in_shop(apps, schema_editor): + DeckVariant = apps.get_model("epic", "DeckVariant") + DeckVariant.objects.filter(slug__in=FREE_SLUGS).update(free_in_shop=True) + + +def unset_free_in_shop(apps, schema_editor): + DeckVariant = apps.get_model("epic", "DeckVariant") + DeckVariant.objects.filter(slug__in=FREE_SLUGS).update(free_in_shop=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0015_deckvariant_free_in_shop"), + ] + + operations = [ + migrations.RunPython(set_free_in_shop, unset_free_in_shop), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index b789612..71981df 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -258,6 +258,11 @@ class DeckVariant(models.Model): family = models.CharField(max_length=10, choices=FAMILY_CHOICES, default=EARTHMAN) has_card_images = models.BooleanField(default=True) is_polarized = models.BooleanField(default=False) + # When True, this deck is offered FREE ($0) in the wallet Shop applet — + # a one-click claim adds it to the user's `unlocked_decks` (no Stripe). + # Seeded True for RWS + Minchiate Fiorentine; Earthman stays False (it's + # auto-granted at signup, not shopped). See migration 0016. + free_in_shop = models.BooleanField(default=False) @property def variant_dir_slug(self): diff --git a/src/functional_tests/test_dash_wallet.py b/src/functional_tests/test_dash_wallet.py index c408748..2f3529d 100644 --- a/src/functional_tests/test_dash_wallet.py +++ b/src/functional_tests/test_dash_wallet.py @@ -3,6 +3,7 @@ from selenium.webdriver.common.by import By from .base import FunctionalTest from apps.applets.models import Applet +from apps.epic.models import DeckVariant from apps.lyric.models import ShopItem, Token, User @@ -431,3 +432,77 @@ class WalletDisplayTest(FunctionalTest): # (BUY → guard portal → NVM dismisses) # - `test_shop_band_already_owned_shows_disabled_buy_btn` # (max_owned cap renders BUY as `.btn-disabled` w. microtext) + + +class WalletShopFreeDeckTest(FunctionalTest): + """Free decks (RWS + Minchiate Fiorentine) appear in the Shop applet at + $0 with a FREE ITEM btn (`.tt-free-btn`) reusing the deck's own tooltip. + Claiming one swaps the btn for an 'Already owned' pill + unlocks the deck + so it renders in the Game Kit applet on the gameboard.""" + + def setUp(self): + super().setUp() + # Wallet applets — Shop lives here. TransactionTestCase flushes the + # migration-seeded Applet rows, so re-seed up front. + Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"}) + Applet.objects.update_or_create( + slug="wallet-shop", + defaults={"name": "Shop", "grid_cols": 12, "grid_rows": 3, + "context": "wallet", "display_order": 10}, + ) + # Gameboard applets — the claim's end state is the deck showing up in + # Game Kit on /gameboard/. + Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"}) + Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"}) + Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"}) + # The two free-in-shop decks (mirror the seed-migration row shapes). + DeckVariant.objects.update_or_create( + slug="tarot-rider-waite-smith", + defaults={"name": "Tarot (Rider-Waite-Smith)", "card_count": 78, + "family": "english", "has_card_images": False, + "is_polarized": False, "free_in_shop": True}, + ) + DeckVariant.objects.update_or_create( + slug="minchiate-fiorentine-1860-1890", + defaults={"name": "Minchiate Fiorentine (1860–1890)", "card_count": 97, + "family": "italian", "has_card_images": True, + "is_polarized": False, "free_in_shop": True, + "description": "97-card Minchiate Fiorentine deck."}, + ) + + def test_claiming_free_deck_swaps_to_owned_and_appears_in_game_kit(self): + self.create_pre_authenticated_session("decker@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. RWS free-deck tile carries the deck's own tooltip + a $0 price + # + a FREE ITEM btn (and NO BUY btn — it's free). + tile = self.browser.find_element(By.ID, "id_shop_tarot-rider-waite-smith") + tt_html = tile.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML") + self.assertIn("Tarot (Rider-Waite-Smith)", tt_html) + self.assertIn("78-card Tarot deck", tt_html) + self.assertIn("$0", tt_html) + free_btn = tile.find_element(By.CSS_SELECTOR, ".tt-free-btn") + # `.text` is '' for the hover-hidden microtooltip btn — read textContent. + self.assertEqual( + free_btn.get_attribute("textContent").strip().upper(), "FREE ITEM" + ) + self.assertEqual(tile.find_elements(By.CSS_SELECTOR, ".tt-buy-btn"), []) + # 2. Claim it (JS-click — the microtooltip lives in the portal layer + # + may not be hover-visible under Selenium's strict-target check). + self.browser.execute_script("arguments[0].click();", free_btn) + # 3. The tile now shows 'Already owned' instead of the FREE ITEM btn. + self.wait_for(lambda: self.assertIn( + "Already owned", + self.browser.find_element( + By.CSS_SELECTOR, "#id_shop_tarot-rider-waite-smith .tt-micro" + ).get_attribute("innerHTML"), + )) + self.assertEqual( + self.browser.find_elements( + By.CSS_SELECTOR, "#id_shop_tarot-rider-waite-smith .tt-free-btn" + ), [], + ) + # 4. The claimed deck now appears in the Game Kit applet — short_key + # of `tarot-rider-waite-smith` is `tarot` → `id_kit_tarot_deck`. + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_tarot_deck")) diff --git a/src/static/tests/WalletShopSpec.js b/src/static/tests/WalletShopSpec.js index a6a1eda..97625a3 100644 --- a/src/static/tests/WalletShopSpec.js +++ b/src/static/tests/WalletShopSpec.js @@ -56,6 +56,19 @@ function _seedShopFixture() { Already owned +
+
+

Tarot (Rider-Waite-Smith)$0

+

78-card Tarot deck

+
+
+ +
+
`; document.body.appendChild(root); return root; @@ -200,4 +213,39 @@ describe('WalletShop.initWalletShop', () => { onDismiss(); expect(window.WalletTooltips.unpin).toHaveBeenCalled(); }); + + // ── F1 ── FREE ITEM click POSTs deck_slug to /shop/claim ──────────────── + it('F1: clicking FREE ITEM POSTs deck_slug to /dashboard/wallet/shop/claim', () => { + // Never-resolving fetch so `_doClaimFree` pauses at the await + never + // reaches `window.location.reload()` (which would nav the spec page). + window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {})); + WalletShop.initWalletShop(); + const btn = fixture.querySelector('#id_shop_tarot-rider-waite-smith .tt-free-btn'); + btn.click(); + expect(window.fetch).toHaveBeenCalled(); + const [url, opts] = window.fetch.calls.mostRecent().args; + expect(url).toContain('/dashboard/wallet/shop/claim'); + expect(opts.method).toBe('POST'); + expect(opts.body).toContain('deck_slug=tarot-rider-waite-smith'); + }); + + // ── F2 ── FREE ITEM is a direct claim — no guard portal (it's free) ───── + it('F2: clicking FREE ITEM does NOT open the guard portal (free = no confirm)', () => { + window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {})); + WalletShop.initWalletShop(); + const btn = fixture.querySelector('#id_shop_tarot-rider-waite-smith .tt-free-btn'); + btn.click(); + expect(window.fetch).toHaveBeenCalled(); + expect(window.showGuard).not.toHaveBeenCalled(); + }); + + // ── F3 ── free-claim wiring is idempotent (init twice → one POST) ─────── + it('F3: calling initWalletShop twice does not double-fire the free claim', () => { + window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {})); + WalletShop.initWalletShop(); + WalletShop.initWalletShop(); + const btn = fixture.querySelector('#id_shop_tarot-rider-waite-smith .tt-free-btn'); + btn.click(); + expect(window.fetch.calls.count()).toBe(1); + }); }); diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index e26d483..9418d0a 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -131,12 +131,20 @@ body.page-gameboard { // card 1 stays put. Tooltip portal is wired to the same `.token:hover` / // `.kit-bag-deck:hover` triggers via JS so splay + tooltip-appearance // co-activate. +// +// `.shop-tile-deck` (the wallet Shop applet's free-deck tiles) rides the +// SAME splay transforms — added to this trigger list rather than re- +// declaring the card-2/card-3 offsets, so the shop decks fan out on +// hover/active exactly like Game Kit. .token.deck-variant:hover .deck-stack-icon, .token.deck-variant:active .deck-stack-icon, .token.deck-variant:focus .deck-stack-icon, .kit-bag-deck:hover .deck-stack-icon, .kit-bag-deck:active .deck-stack-icon, -.kit-bag-deck:focus .deck-stack-icon { +.kit-bag-deck:focus .deck-stack-icon, +.shop-tile-deck:hover .deck-stack-icon, +.shop-tile-deck:active .deck-stack-icon, +.shop-tile-deck:focus .deck-stack-icon { .deck-stack-icon__card--2 { transform: translate(-5px, -2px) rotate(-12deg); } .deck-stack-icon__card--3 { transform: translate( 5px, -2px) rotate( 12deg); } } diff --git a/src/static_src/scss/_wallet-tokens.scss b/src/static_src/scss/_wallet-tokens.scss index 4fb4b84..32102a2 100644 --- a/src/static_src/scss/_wallet-tokens.scss +++ b/src/static_src/scss/_wallet-tokens.scss @@ -138,9 +138,10 @@ // 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). +// the BUY-ITEM / FREE-ITEM btn label to wrap onto multiple lines +// (gameboard's mini holds short status text like "In-Use: X" which +// wants nowrap; our round action btns need the 2-word label to break +// onto 2 lines). #id_mini_tooltip_portal { position: fixed; z-index: 9999; @@ -152,7 +153,10 @@ align-items: center; gap: 0.25rem; - .tt-buy-btn { + // Free-deck claim btn ($0 shop decks) shares the round BUY-ITEM shape + // + 2-line label wrap so "FREE ITEM" breaks to FREE / ITEM like BUY. + .tt-buy-btn, + .tt-free-btn { padding: 0.25rem 0.75rem; white-space: normal; word-break: normal; diff --git a/src/static_src/tests/WalletShopSpec.js b/src/static_src/tests/WalletShopSpec.js index a6a1eda..97625a3 100644 --- a/src/static_src/tests/WalletShopSpec.js +++ b/src/static_src/tests/WalletShopSpec.js @@ -56,6 +56,19 @@ function _seedShopFixture() { Already owned +
+
+

Tarot (Rider-Waite-Smith)$0

+

78-card Tarot deck

+
+
+ +
+
`; document.body.appendChild(root); return root; @@ -200,4 +213,39 @@ describe('WalletShop.initWalletShop', () => { onDismiss(); expect(window.WalletTooltips.unpin).toHaveBeenCalled(); }); + + // ── F1 ── FREE ITEM click POSTs deck_slug to /shop/claim ──────────────── + it('F1: clicking FREE ITEM POSTs deck_slug to /dashboard/wallet/shop/claim', () => { + // Never-resolving fetch so `_doClaimFree` pauses at the await + never + // reaches `window.location.reload()` (which would nav the spec page). + window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {})); + WalletShop.initWalletShop(); + const btn = fixture.querySelector('#id_shop_tarot-rider-waite-smith .tt-free-btn'); + btn.click(); + expect(window.fetch).toHaveBeenCalled(); + const [url, opts] = window.fetch.calls.mostRecent().args; + expect(url).toContain('/dashboard/wallet/shop/claim'); + expect(opts.method).toBe('POST'); + expect(opts.body).toContain('deck_slug=tarot-rider-waite-smith'); + }); + + // ── F2 ── FREE ITEM is a direct claim — no guard portal (it's free) ───── + it('F2: clicking FREE ITEM does NOT open the guard portal (free = no confirm)', () => { + window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {})); + WalletShop.initWalletShop(); + const btn = fixture.querySelector('#id_shop_tarot-rider-waite-smith .tt-free-btn'); + btn.click(); + expect(window.fetch).toHaveBeenCalled(); + expect(window.showGuard).not.toHaveBeenCalled(); + }); + + // ── F3 ── free-claim wiring is idempotent (init twice → one POST) ─────── + it('F3: calling initWalletShop twice does not double-fire the free claim', () => { + window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {})); + WalletShop.initWalletShop(); + WalletShop.initWalletShop(); + const btn = fixture.querySelector('#id_shop_tarot-rider-waite-smith .tt-free-btn'); + btn.click(); + expect(window.fetch.calls.count()).toBe(1); + }); }); diff --git a/src/templates/apps/wallet/_partials/_applet-wallet-shop.html b/src/templates/apps/wallet/_partials/_applet-wallet-shop.html index c57948a..be474e8 100644 --- a/src/templates/apps/wallet/_partials/_applet-wallet-shop.html +++ b/src/templates/apps/wallet/_partials/_applet-wallet-shop.html @@ -61,5 +61,44 @@ {% endfor %} + {% comment %} + Free decks (RWS + Minchiate Fiorentine) — $0 shop items that grant a + DeckVariant instead of a token. They reuse the DECK's own Game Kit + tooltip prose (name / card-count / description / stock-version line) + rather than the ShopItem tooltip, plus a `.tt-price` of "$0". The + micro carries a `.tt-free-btn` (claim) or the same `.tt-already-owned` + pill once unlocked. wallet-shop.js POSTs the claim to /shop/claim, + which adds the deck to `unlocked_decks` (→ Game Kit on /gameboard/). + {% endcomment %} + {% for deck in free_decks %} +
+ {% include "apps/gameboard/_partials/_deck_stack_icon.html" %} +
+

+ {{ deck.name }} + $0 +

+

{{ deck.card_count }}-card Tarot deck{% if deck.is_polarized %} (×2){% endif %}

+ {% if deck.description %}

{{ deck.description }}

{% endif %} +

Stock version (0 substitutions)

+
+
+ {% if deck.owned %} + Already owned + {% else %} + + {% endif %} +
+
+ {% endfor %}