wallet shop: free ($0) RWS + Fiorentine decks — FREE ITEM claim unlocks to Game Kit — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

- 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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-30 14:51:21 -04:00
parent d8377b57bc
commit 86a349b64e
13 changed files with 495 additions and 7 deletions

View File

@@ -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) { function _onBuyClick(e) {
const btn = e.target.closest('.tt-buy-btn'); const btn = e.target.closest('.tt-buy-btn');
if (!btn) return; if (!btn) return;
@@ -139,11 +171,21 @@ const WalletShop = (function () {
// (c) cloned mini portal (`#id_mini_tooltip_portal`) — production // (c) cloned mini portal (`#id_mini_tooltip_portal`) — production
// path post-microtooltip refactor; the BUY btn lives in // path post-microtooltip refactor; the BUY btn lives in
// `.tt-micro` which clones into the mini portal on hover. // `.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', _onBuyClick);
shopRoot.addEventListener('click', _onFreeClick);
const portal = document.getElementById('id_tooltip_portal'); 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'); 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'; shopRoot.dataset.shopWired = '1';
} }

View File

@@ -21,9 +21,143 @@ from unittest import mock
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from apps.epic.models import DeckVariant
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User 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 (18601890)", "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(): def _seed_starting_items():
"""Mirror the seed-migration row shape so each TestCase starts w. a """Mirror the seed-migration row shape so each TestCase starts w. a
known catalog (TestCase rolls back the data migration, so the rows known catalog (TestCase rolls back the data migration, so the rows

View File

@@ -12,6 +12,7 @@ urlpatterns = [
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'), 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/buy', views.shop_buy, name='shop_buy'),
path('wallet/shop/confirm', views.shop_confirm, name='shop_confirm'), 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('kit-bag/', views.kit_bag, name='kit_bag'),
path('sky/', views.sky_view, name='sky'), path('sky/', views.sky_view, name='sky'),
path('sky/preview', views.sky_preview, name='sky_preview'), path('sky/preview', views.sky_preview, name='sky_preview'),

View File

@@ -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.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import Note from apps.drama.models import Note
from apps.epic.models import DeckVariant
from apps.epic.utils import _compute_distinctions from apps.epic.utils import _compute_distinctions
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username 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 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="/") @login_required(login_url="/")
@ensure_csrf_cookie @ensure_csrf_cookie
def wallet(request): def wallet(request):
@@ -204,6 +217,7 @@ def wallet(request):
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"shop_items": shop_items, "shop_items": shop_items,
"free_decks": _free_decks_for(request.user),
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "", "default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY, "stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
"free_tokens": free_tokens, "free_tokens": free_tokens,
@@ -246,6 +260,7 @@ def toggle_wallet_applets(request):
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"shop_items": _shop_items_for(request.user), "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 "", "default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY, "stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
@@ -382,6 +397,26 @@ def shop_confirm(request):
return JsonResponse({"status": purchase.status}) 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 @csrf_exempt
def stripe_webhook(request): def stripe_webhook(request):
"""Stripe webhook listener. Verifies signature against """Stripe webhook listener. Verifies signature against

View File

@@ -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),
),
]

View File

@@ -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),
]

View File

@@ -258,6 +258,11 @@ class DeckVariant(models.Model):
family = models.CharField(max_length=10, choices=FAMILY_CHOICES, default=EARTHMAN) family = models.CharField(max_length=10, choices=FAMILY_CHOICES, default=EARTHMAN)
has_card_images = models.BooleanField(default=True) has_card_images = models.BooleanField(default=True)
is_polarized = models.BooleanField(default=False) 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 @property
def variant_dir_slug(self): def variant_dir_slug(self):

View File

@@ -3,6 +3,7 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.epic.models import DeckVariant
from apps.lyric.models import ShopItem, Token, User from apps.lyric.models import ShopItem, Token, User
@@ -431,3 +432,77 @@ class WalletDisplayTest(FunctionalTest):
# (BUY → guard portal → NVM dismisses) # (BUY → guard portal → NVM dismisses)
# - `test_shop_band_already_owned_shows_disabled_buy_btn` # - `test_shop_band_already_owned_shows_disabled_buy_btn`
# (max_owned cap renders BUY as `.btn-disabled` w. microtext) # (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 (18601890)", "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"))

View File

@@ -56,6 +56,19 @@ function _seedShopFixture() {
<span class="tt-already-owned">Already owned</span> <span class="tt-already-owned">Already owned</span>
</div> </div>
</div> </div>
<div id="id_shop_tarot-rider-waite-smith"
class="shop-tile shop-tile-deck"
data-deck-slug="tarot-rider-waite-smith">
<div class="tt">
<h4 class="tt-title"><span>Tarot (Rider-Waite-Smith)</span><span class="tt-price">$0</span></h4>
<p class="tt-description">78-card Tarot deck</p>
</div>
<div class="tt-micro">
<button class="btn btn-primary tt-free-btn"
data-deck-slug="tarot-rider-waite-smith"
data-item-name="Tarot (Rider-Waite-Smith)">FREE ITEM</button>
</div>
</div>
`; `;
document.body.appendChild(root); document.body.appendChild(root);
return root; return root;
@@ -200,4 +213,39 @@ describe('WalletShop.initWalletShop', () => {
onDismiss(); onDismiss();
expect(window.WalletTooltips.unpin).toHaveBeenCalled(); 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);
});
}); });

View File

@@ -131,12 +131,20 @@ body.page-gameboard {
// card 1 stays put. Tooltip portal is wired to the same `.token:hover` / // card 1 stays put. Tooltip portal is wired to the same `.token:hover` /
// `.kit-bag-deck:hover` triggers via JS so splay + tooltip-appearance // `.kit-bag-deck:hover` triggers via JS so splay + tooltip-appearance
// co-activate. // 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:hover .deck-stack-icon,
.token.deck-variant:active .deck-stack-icon, .token.deck-variant:active .deck-stack-icon,
.token.deck-variant:focus .deck-stack-icon, .token.deck-variant:focus .deck-stack-icon,
.kit-bag-deck:hover .deck-stack-icon, .kit-bag-deck:hover .deck-stack-icon,
.kit-bag-deck:active .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--2 { transform: translate(-5px, -2px) rotate(-12deg); }
.deck-stack-icon__card--3 { transform: translate( 5px, -2px) rotate( 12deg); } .deck-stack-icon__card--3 { transform: translate( 5px, -2px) rotate( 12deg); }
} }

View File

@@ -138,9 +138,10 @@
// Wallet-side mini portal — pinned to the bottom-right of the main // Wallet-side mini portal — pinned to the bottom-right of the main
// portal by wallet.js (mirrors gameboard.js's gameKit positioning). // portal by wallet.js (mirrors gameboard.js's gameKit positioning).
// Mostly mirrors gameboard's mini at `_gameboard.scss:140` but allows // Mostly mirrors gameboard's mini at `_gameboard.scss:140` but allows
// the BUY-ITEM btn label to wrap onto multiple lines (gameboard's // the BUY-ITEM / FREE-ITEM btn label to wrap onto multiple lines
// mini holds short status text like "In-Use: X" which wants nowrap; // (gameboard's mini holds short status text like "In-Use: X" which
// our buy btn is round + needs the label to break onto 2 lines). // wants nowrap; our round action btns need the 2-word label to break
// onto 2 lines).
#id_mini_tooltip_portal { #id_mini_tooltip_portal {
position: fixed; position: fixed;
z-index: 9999; z-index: 9999;
@@ -152,7 +153,10 @@
align-items: center; align-items: center;
gap: 0.25rem; 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; padding: 0.25rem 0.75rem;
white-space: normal; white-space: normal;
word-break: normal; word-break: normal;

View File

@@ -56,6 +56,19 @@ function _seedShopFixture() {
<span class="tt-already-owned">Already owned</span> <span class="tt-already-owned">Already owned</span>
</div> </div>
</div> </div>
<div id="id_shop_tarot-rider-waite-smith"
class="shop-tile shop-tile-deck"
data-deck-slug="tarot-rider-waite-smith">
<div class="tt">
<h4 class="tt-title"><span>Tarot (Rider-Waite-Smith)</span><span class="tt-price">$0</span></h4>
<p class="tt-description">78-card Tarot deck</p>
</div>
<div class="tt-micro">
<button class="btn btn-primary tt-free-btn"
data-deck-slug="tarot-rider-waite-smith"
data-item-name="Tarot (Rider-Waite-Smith)">FREE ITEM</button>
</div>
</div>
`; `;
document.body.appendChild(root); document.body.appendChild(root);
return root; return root;
@@ -200,4 +213,39 @@ describe('WalletShop.initWalletShop', () => {
onDismiss(); onDismiss();
expect(window.WalletTooltips.unpin).toHaveBeenCalled(); 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);
});
}); });

View File

@@ -61,5 +61,44 @@
</div> </div>
</div> </div>
{% endfor %} {% 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 %}
<div
id="id_shop_{{ deck.slug }}"
class="shop-tile shop-tile-deck"
data-deck-slug="{{ deck.slug }}"
>
{% include "apps/gameboard/_partials/_deck_stack_icon.html" %}
<div class="tt">
<h4 class="tt-title">
<span>{{ deck.name }}</span>
<span class="tt-price">$0</span>
</h4>
<p class="tt-description">{{ deck.card_count }}-card Tarot deck{% if deck.is_polarized %} <span class="tt-x2">(×2)</span>{% endif %}</p>
{% if deck.description %}<p class="tt-shoptalk"><em>{{ deck.description }}</em></p>{% endif %}
<p class="tt-shoptalk">Stock version <span class="tt-subcounter">(0 substitutions)</span></p>
</div>
<div class="tt-micro">
{% if deck.owned %}
<span class="tt-already-owned">Already owned</span>
{% else %}
<button
type="button"
class="btn btn-primary tt-free-btn"
data-deck-slug="{{ deck.slug }}"
data-item-name="{{ deck.name }}"
>FREE ITEM</button>
{% endif %}
</div>
</div>
{% endfor %}
</div> </div>
</section> </section>