wallet shop: free ($0) RWS + Fiorentine decks — FREE ITEM claim unlocks to Game Kit — TDD
- 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:
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (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():
|
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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
18
src/apps/epic/migrations/0015_deckvariant_free_in_shop.py
Normal file
18
src/apps/epic/migrations/0015_deckvariant_free_in_shop.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
31
src/apps/epic/migrations/0016_seed_free_in_shop_decks.py
Normal file
31
src/apps/epic/migrations/0016_seed_free_in_shop_decks.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 (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"))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user