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) {
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';
}

View File

@@ -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 (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():
"""Mirror the seed-migration row shape so each TestCase starts w. a
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/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'),

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.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

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)
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):