Compare commits

...

2 Commits

24 changed files with 928 additions and 48 deletions

View File

@@ -13,4 +13,5 @@ urlpatterns = [
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'), path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
path('wallet/setup-intent', views.setup_intent, name='setup_intent'), path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
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('kit-bag/', views.kit_bag, name='kit_bag'),
] ]

View File

@@ -146,6 +146,7 @@ def toggle_applets(request):
def wallet(request): def wallet(request):
return render(request, "apps/dashboard/wallet.html", { return render(request, "apps/dashboard/wallet.html", {
"wallet": request.user.wallet, "wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
@@ -153,6 +154,12 @@ def wallet(request):
"page_class": "page-wallet", "page_class": "page-wallet",
}) })
@login_required(login_url="/")
def kit_bag(request):
tokens = list(request.user.tokens.all())
return render(request, "core/_partials/_kit_bag_panel.html", {"tokens": tokens})
@login_required(login_url="/") @login_required(login_url="/")
def toggle_wallet_applets(request): def toggle_wallet_applets(request):
checked = request.POST.getlist("applets") checked = request.POST.getlist("applets")
@@ -166,6 +173,7 @@ def toggle_wallet_applets(request):
return render(request, "apps/wallet/_partials/_applets.html", { return render(request, "apps/wallet/_partials/_applets.html", {
"applets": applet_context(request.user, "wallet"), "applets": applet_context(request.user, "wallet"),
"wallet": request.user.wallet, "wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-03-15 00:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0003_roominvite'),
]
operations = [
migrations.AlterField(
model_name='room',
name='gate_status',
field=models.CharField(choices=[('GATHERING', 'GATHERING GAMERS'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20),
),
]

View File

@@ -93,13 +93,30 @@ def create_gate_slots(sender, instance, created, **kwargs):
GateSlot.objects.create(room=instance, slot_number=i) GateSlot.objects.create(room=instance, slot_number=i)
def select_token(user):
if user.is_staff:
pass_token = user.tokens.filter(token_type=Token.PASS).first()
if pass_token:
return pass_token
coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first()
if coin:
return coin
free = user.tokens.filter(
token_type=Token.FREE,
expires_at__gt=timezone.now(),
).order_by("expires_at").first()
if free:
return free
return user.tokens.filter(token_type=Token.TITHE).first()
def debit_token(user, slot, token): def debit_token(user, slot, token):
if token.token_type == Token.COIN: if token.token_type == Token.COIN:
token.current_room = slot.room token.current_room = slot.room
period = slot.room.renewal_period or timedelta(days=7) period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period token.next_ready_at = timezone.now() + period
token.save() token.save()
else: elif token.token_type != Token.PASS:
token.delete() token.delete()
slot.gamer = user slot.gamer = user
slot.status = GateSlot.FILLED slot.status = GateSlot.FILLED

View File

@@ -2,9 +2,10 @@ from datetime import timedelta
from django.db.models import Q from django.db.models import Q
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
class RoomCreationTest(TestCase): class RoomCreationTest(TestCase):
@@ -77,6 +78,60 @@ class CoinTokenInUseTest(TestCase):
self.assertIn(self.room.name, html) self.assertIn(self.room.name, html)
class SelectTokenTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.other_room = Room.objects.create(name="Other Room", owner=self.user)
self.coin = Token.objects.get(user=self.user, token_type=Token.COIN)
def test_returns_coin_when_available(self):
token = select_token(self.user)
self.assertEqual(token.token_type, Token.COIN)
def test_returns_free_token_when_coin_in_use(self):
self.coin.current_room = self.other_room
self.coin.save()
token = select_token(self.user)
self.assertEqual(token.token_type, Token.FREE)
def test_free_token_selection_is_fefo(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
soon = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=2),
)
Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=6),
)
token = select_token(self.user)
self.assertEqual(token.pk, soon.pk)
def test_returns_tithe_when_coin_in_use_and_no_free_tokens(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
token = select_token(self.user)
self.assertEqual(token.pk, tithe.pk)
def test_returns_none_when_all_depleted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
token = select_token(self.user)
self.assertIsNone(token)
def test_returns_pass_for_staff(self):
self.user.is_staff = True
self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
token = select_token(self.user)
self.assertEqual(token.token_type, Token.PASS)
class RoomInviteTest(TestCase): class RoomInviteTest(TestCase):
def setUp(self): def setUp(self):
self.founder = User.objects.create(email="founder@example.com") self.founder = User.objects.create(email="founder@example.com")

View File

@@ -229,6 +229,81 @@ class RejectTokenViewTest(TestCase):
) )
class DropTokenAvailabilityViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
self.other_room = Room.objects.create(name="Other Room", owner=owner)
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
def test_drop_reserves_slot_when_tokens_available(self):
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot = self.room.gate_slots.get(slot_number=1)
self.assertEqual(slot.status, GateSlot.RESERVED)
# token not debited yet — that happens at confirm
self.coin.refresh_from_db()
self.assertIsNone(self.coin.current_room)
def test_drop_returns_402_when_all_tokens_depleted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
response = self.client.post(
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
)
self.assertEqual(response.status_code, 402)
class ConfirmTokenPriorityViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
self.other_room = Room.objects.create(name="Other Room", owner=owner)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.gamer
self.slot.status = GateSlot.RESERVED
self.slot.reserved_at = timezone.now()
self.slot.save()
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
def test_confirm_leases_coin_to_room(self):
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, self.room)
self.assertTrue(Token.objects.filter(pk=self.coin.pk).exists())
def test_confirm_uses_free_token_when_coin_in_use(self):
self.coin.current_room = self.other_room
self.coin.save()
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertEqual(
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0
)
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, self.other_room)
def test_confirm_uses_tithe_when_free_tokens_exhausted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
def test_pass_not_consumed_and_coin_not_leased(self):
self.gamer.is_staff = True
self.gamer.save()
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
self.coin.refresh_from_db()
self.assertIsNone(self.coin.current_room)
class RoomActionsViewTest(TestCase): class RoomActionsViewTest(TestCase):
def setUp(self): def setUp(self):
self.owner = User.objects.create(email="owner@test.io") self.owner = User.objects.create(email="owner@test.io")

View File

@@ -5,8 +5,7 @@ from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils import timezone from django.utils import timezone
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
from apps.lyric.models import Token
RESERVE_TIMEOUT = timedelta(seconds=60) RESERVE_TIMEOUT = timedelta(seconds=60)
@@ -29,12 +28,14 @@ def _gate_context(room, user):
if user.is_authenticated: if user.is_authenticated:
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first() user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first() user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first()
can_drop = ( eligible = (
user.is_authenticated user.is_authenticated
and pending_slot is None and pending_slot is None
and user_reserved_slot is None and user_reserved_slot is None
and user_filled_slot is None and user_filled_slot is None
) )
token_depleted = eligible and select_token(user) is None
can_drop = eligible and not token_depleted
is_last_slot = ( is_last_slot = (
user_reserved_slot is not None user_reserved_slot is not None
and slots.filter(status=GateSlot.EMPTY).count() == 0 and slots.filter(status=GateSlot.EMPTY).count() == 0
@@ -46,6 +47,7 @@ def _gate_context(room, user):
"user_reserved_slot": user_reserved_slot, "user_reserved_slot": user_reserved_slot,
"user_filled_slot": user_filled_slot, "user_filled_slot": user_filled_slot,
"can_drop": can_drop, "can_drop": can_drop,
"token_depleted": token_depleted,
"is_last_slot": is_last_slot, "is_last_slot": is_last_slot,
"user_can_reject": user_can_reject, "user_can_reject": user_can_reject,
} }
@@ -76,6 +78,13 @@ def drop_token(request, room_id):
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists(): if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
token_id = request.POST.get("token_id")
if token_id:
token = request.user.tokens.filter(id=token_id).first()
else:
token = select_token(request.user)
if token is None:
return HttpResponse(status=402)
slot = room.gate_slots.filter( slot = room.gate_slots.filter(
status=GateSlot.EMPTY status=GateSlot.EMPTY
).order_by("slot_number").first() ).order_by("slot_number").first()
@@ -84,6 +93,7 @@ def drop_token(request, room_id):
slot.status = GateSlot.RESERVED slot.status = GateSlot.RESERVED
slot.reserved_at = timezone.now() slot.reserved_at = timezone.now()
slot.save() slot.save()
request.session["kit_token_id"] = str(token.id)
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
@@ -95,11 +105,12 @@ def confirm_token(request, room_id):
gamer=request.user, status=GateSlot.RESERVED gamer=request.user, status=GateSlot.RESERVED
).first() ).first()
if slot: if slot:
token = ( token_id = request.session.pop("kit_token_id", None)
request.user.tokens.filter(token_type=Token.COIN).first() token = None
or request.user.tokens.filter(token_type=Token.FREE).first() if token_id:
or request.user.tokens.filter(token_type=Token.TITHE).first() token = request.user.tokens.filter(id=token_id).first()
) if not token:
token = select_token(request.user)
if token: if token:
debit_token(request.user, slot, token) debit_token(request.user, slot, token)
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -17,10 +17,12 @@ GAMEBOARD_APPLET_ORDER = [
@login_required(login_url="/") @login_required(login_url="/")
def gameboard(request): def gameboard(request):
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
coin = request.user.tokens.filter(token_type=Token.COIN).first() coin = request.user.tokens.filter(token_type=Token.COIN).first()
free_tokens = list(request.user.tokens.filter(token_type=Token.FREE)) free_tokens = list(request.user.tokens.filter(token_type=Token.FREE))
return render( return render(
request, "apps/gameboard/gameboard.html", { request, "apps/gameboard/gameboard.html", {
"pass_token": pass_token,
"coin": coin, "coin": coin,
"free_tokens": free_tokens, "free_tokens": free_tokens,
"applets": applet_context(request.user, "gameboard"), "applets": applet_context(request.user, "gameboard"),
@@ -45,6 +47,7 @@ def toggle_game_applets(request):
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
return render(request, "apps/gameboard/_partials/_applets.html", { return render(request, "apps/gameboard/_partials/_applets.html", {
"applets": applet_context(request.user, "gameboard"), "applets": applet_context(request.user, "gameboard"),
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"my_games": Room.objects.filter( "my_games": Room.objects.filter(

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-03-15 00:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0008_token_current_room_token_next_ready_at'),
]
operations = [
migrations.AlterField(
model_name='token',
name='token_type',
field=models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass')], max_length=8),
),
]

View File

@@ -71,10 +71,12 @@ class Token(models.Model):
COIN = "coin" COIN = "coin"
FREE = "Free" FREE = "Free"
TITHE = "tithe" TITHE = "tithe"
PASS = "pass"
TOKEN_TYPE_CHOICES = [ TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"), (COIN, "Coin-on-a-String"),
(FREE, "Free Token"), (FREE, "Free Token"),
(TITHE, "Tithe Token"), (TITHE, "Tithe Token"),
(PASS, "Backstage Pass"),
] ]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
@@ -90,15 +92,15 @@ class Token(models.Model):
return self.get_token_type_display() return self.get_token_type_display()
def tooltip_description(self): def tooltip_description(self):
if self.token_type in (self.COIN, self.FREE): if self.token_type in (self.COIN, self.FREE, self.PASS):
return "Admit 1 Entry" return "Admit 1 Entry"
if self.token_type == self.TITHE: if self.token_type == self.TITHE:
return "+ Writ bonus" return "+ Writ bonus"
return "" return ""
def tooltip_expiry(self): def tooltip_expiry(self):
if self.token_type == self.COIN: if self.token_type in (self.COIN, self.PASS):
if self.next_ready_at: if self.token_type == self.COIN and self.next_ready_at:
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}" return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
return "no expiry" return "no expiry"
if self.expires_at: if self.expires_at:
@@ -114,6 +116,8 @@ class Token(models.Model):
def tooltip_shoptalk(self): def tooltip_shoptalk(self):
if self.token_type == self.COIN: if self.token_type == self.COIN:
return "\u2026and another after that, and another after that\u2026" return "\u2026and another after that, and another after that\u2026"
if self.token_type == self.PASS:
return "\u2018Entry fee\u2019? Do you know who you\u2019re talking to?"
return None return None
def tooltip_text(self): def tooltip_text(self):
@@ -143,3 +147,5 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs):
token_type=Token.FREE, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7), expires_at=timezone.now() + timedelta(days=7),
) )
if instance.is_staff:
Token.objects.create(user=instance, token_type=Token.PASS)

View File

@@ -97,6 +97,30 @@ class TokenCreationTest(TestCase):
self.assertLessEqual(delta.days, 7) self.assertLessEqual(delta.days, 7)
self.assertGreater(delta.total_seconds(), 0) self.assertGreater(delta.total_seconds(), 0)
def test_no_pass_token_for_regular_user(self):
self.assertFalse(
Token.objects.filter(user=self.user, token_type=Token.PASS).exists()
)
class SuperuserTokenCreationTest(TestCase):
def setUp(self):
self.user = User.objects.create_superuser(
email="admin@test.io", password="secret"
)
def test_pass_token_created_for_superuser(self):
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.PASS).exists()
)
def test_superuser_also_gets_coin_and_free_token(self):
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.COIN).exists()
)
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.FREE).exists()
)
class WalletTooltipTest(TestCase): class WalletTooltipTest(TestCase):
def setUp(self): def setUp(self):

View File

@@ -41,3 +41,17 @@ class FreeTokenTooltipTest(SimpleTestCase):
def test_tooltip_contains_expiry_date(self): def test_tooltip_contains_expiry_date(self):
self.assertIn("2026-03-15", self.token.tooltip_text()) self.assertIn("2026-03-15", self.token.tooltip_text())
class PassTokenTooltipTest(SimpleTestCase):
def setUp(self):
self.token = Token()
self.token.token_type = Token.PASS
self.token.expires_at = None
self.token.next_ready_at = None
def test_tooltip_contains_name(self):
self.assertIn("Backstage Pass", self.token.tooltip_text())
def test_tooltip_contains_no_expiry(self):
self.assertIn("no expiry", self.token.tooltip_text())

View File

@@ -0,0 +1,61 @@
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
from apps.epic.models import Room
from apps.lyric.models import Token, User
class GameKitTest(FunctionalTest):
"""Game Kit <dialog>: opens from footer, shows token cards, dismisses."""
def setUp(self):
super().setUp()
self.create_pre_authenticated_session("gamer@kit.io")
self.gamer = User.objects.get(email="gamer@kit.io")
self.token = self.gamer.tokens.filter(token_type=Token.COIN).first()
self.room = Room.objects.create(name="Kit Room", owner=self.gamer)
self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/"
def test_kit_btn_in_footer_opens_dialog(self):
self.browser.get(self.gate_url)
kit_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_kit_btn")
)
self.assertTrue(kit_btn.is_displayed())
kit_btn.click()
dialog = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_kit_bag_dialog")
)
self.assertTrue(dialog.is_displayed())
def test_kit_dialog_shows_token_cards(self):
self.browser.get(self.gate_url)
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f"#id_kit_bag_dialog [data-token-id='{self.token.id}']",
)
)
def test_kit_dialog_closes_on_escape(self):
self.browser.get(self.gate_url)
self.browser.find_element(By.ID, "id_kit_btn").click()
dialog = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_kit_bag_dialog")
)
self.assertTrue(dialog.is_displayed())
dialog.send_keys(Keys.ESCAPE)
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_kit_bag_dialog").is_displayed()
)
)
def test_kit_btn_visible_outside_room(self):
self.browser.get(self.live_server_url + "/")
kit_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_kit_btn")
)
self.assertTrue(kit_btn.is_displayed())

View File

@@ -1,13 +1,13 @@
import time import time
from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from selenium.webdriver.common.by import By 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 Room, GateSlot from apps.epic.models import Room, GateSlot, select_token
from apps.lyric.models import User from apps.lyric.models import Token, User
class GatekeeperTest(FunctionalTest): class GatekeeperTest(FunctionalTest):
@@ -39,7 +39,7 @@ class GatekeeperTest(FunctionalTest):
) )
# 4. Page shows room name, GATHERING status # 4. Page shows room name, GATHERING status
body = self.browser.find_element(By.TAG_NAME, "body") body = self.browser.find_element(By.TAG_NAME, "body")
self.assertIn("Test Room", body.text) self.assertIn("TEST ROOM", body.text)
self.assertIn("GATHERING GAMERS", body.text) self.assertIn("GATHERING GAMERS", body.text)
# 5. Six token slot circles are visible, all empty # 5. Six token slot circles are visible, all empty
slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot")
@@ -82,7 +82,7 @@ class GatekeeperTest(FunctionalTest):
slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot")
self.assertIn("filled", slots[0].get_attribute("class")) self.assertIn("filled", slots[0].get_attribute("class"))
self.assertEqual( self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0
) )
def test_room_appears_in_my_games_after_creation(self): def test_room_appears_in_my_games_after_creation(self):
@@ -308,7 +308,7 @@ class CoinSlotTest(FunctionalTest):
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
) )
self.assertEqual( self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0
) )
slot = self.room.gate_slots.get(slot_number=1) slot = self.room.gate_slots.get(slot_number=1)
slot.refresh_from_db() slot.refresh_from_db()
@@ -374,5 +374,209 @@ class CoinSlotTest(FunctionalTest):
) )
) )
self.assertEqual( self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0
) )
class TokenPriorityTest(FunctionalTest):
def setUp(self):
super().setUp()
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"}
)
self.create_pre_authenticated_session("gamer@test.io")
self.gamer = User.objects.get(email="gamer@test.io")
self.room = Room.objects.create(name="Token Room", owner=self.gamer)
self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/"
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
def test_coin_is_used_by_default(self):
# 1. COIN token created at signup, not yet leased to a room
self.assertEqual(self.coin.token_type, Token.COIN)
self.assertIsNone(self.coin.current_room)
# 2. Gamer drops token and confirms
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
# 3. Coin is now leased to this room, page not refreshed
self.assertEqual(self.browser.current_url, self.gate_url)
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, self.room)
def test_free_token_used_when_coin_in_use(self):
# 1. Coin already leased to another room
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
self.coin.current_room = other_room
self.coin.save()
# 2. Gamer has one unexpired free token (signup gives one; delete it and add fresh)
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
Token.objects.create(
user=self.gamer,
token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
# 3. Gamer drops token → Free Token consumed
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
self.assertEqual(
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0
)
# 4. Coin untouched, still leased to other room
self.assertEqual(self.browser.current_url, self.gate_url)
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, other_room)
def test_tithe_token_used_when_free_tokens_exhausted(self):
# 1. Coin in use, no Free Tokens, one Tithe Token
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
self.coin.current_room = other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
# 2. Gamer drops token → tithe consumed
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
# Tithe row deleted, page hasn't refreshed
self.assertEqual(self.browser.current_url, self.gate_url)
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
def test_slot_blocked_when_no_tokens_available(self):
# Coin in use, no Free Tokens, no Tithe Tokens → depleted state
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
self.coin.current_room = other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.depleted")
)
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "button.token-rails")), 0
)
def test_staff_backstage_pass_bypasses_token_cost(self):
# 1. Staff user has a PASS token
self.gamer.is_staff = True
self.gamer.save()
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
# 2. Drops token, confirms as normal
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
# 3. Pass not consumed, coin not leased; no reload
self.assertEqual(self.browser.current_url, self.gate_url)
self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
self.coin.refresh_from_db()
self.assertIsNone(self.coin.current_room)
class GameKitInsertTest(FunctionalTest):
"""Token selected from Game Kit, inserted via token-slot click."""
def setUp(self):
super().setUp()
self.create_pre_authenticated_session("gamer@insert.io")
self.gamer = User.objects.get(email="gamer@insert.io")
self.coin = self.gamer.tokens.filter(token_type=Token.COIN).first()
self.room = Room.objects.create(name="Insert Room", owner=self.gamer)
self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/"
def _select_token_from_kit(self, token):
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, f"[data-token-id='{token.id}']"
).click()
)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.ready")
)
def test_coin_insert_via_kit_reserves_slot(self):
self.browser.get(self.gate_url)
self._select_token_from_kit(self.coin)
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.reserved")
)
self.assertEqual(self.browser.current_url, self.gate_url)
def test_free_token_insert_via_kit_consumed_on_confirm(self):
token = Token.objects.create(
user=self.gamer,
token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.browser.get(self.gate_url)
self._select_token_from_kit(token)
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='1'] .btn-confirm"
)
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
self.assertFalse(Token.objects.filter(id=token.id).exists())
def test_tithe_token_insert_via_kit_consumed_on_confirm(self):
token = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
self.browser.get(self.gate_url)
self._select_token_from_kit(token)
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='1'] .btn-confirm"
)
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
self.assertFalse(Token.objects.filter(id=token.id).exists())
def test_pass_token_insert_via_kit_not_consumed(self):
self.gamer.is_staff = True
self.gamer.save()
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
self.browser.get(self.gate_url)
self._select_token_from_kit(pass_token)
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.reserved")
)
self.assertTrue(Token.objects.filter(id=pass_token.id).exists())
self.assertEqual(self.browser.current_url, self.gate_url)

View File

@@ -77,6 +77,27 @@
#id_wallet_applet_menu { @extend %applet-menu; } #id_wallet_applet_menu { @extend %applet-menu; }
#id_room_menu { @extend %applet-menu; } #id_room_menu { @extend %applet-menu; }
// Page-level gear buttons — fixed to viewport bottom-right
.gameboard-page,
.dashboard-page,
.wallet-page {
> .gear-btn {
position: fixed;
bottom: 4.2rem;
right: 0.5rem;
z-index: 202;
}
}
#id_dash_applet_menu,
#id_game_applet_menu,
#id_wallet_applet_menu {
position: fixed;
bottom: 6.6rem;
right: 1rem;
z-index: 202;
}
// ── Applets grid (shared across all boards) ──────────────── // ── Applets grid (shared across all boards) ────────────────
%applets-grid { %applets-grid {
container-type: inline-size; container-type: inline-size;

View File

@@ -0,0 +1,123 @@
#id_kit_btn {
position: fixed;
bottom: 0.5rem;
right: 0.5rem;
z-index: 205;
font-size: 1.75rem;
cursor: pointer;
color: rgba(var(--secUser), 1);
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1);
&:hover,
&.active {
color: rgba(var(--quaUser), 1);
border-color: rgba(var(--quaUser), 1);
}
}
#id_kit_bag_dialog {
// Override dialog's native display:none so we can drive visibility via max-height
display: block !important;
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
max-width: none;
margin: 0;
padding: 0;
border: none;
border-top: 0.1rem solid rgba(var(--terUser), 0.3);
background: rgba(var(--priUser), 0.97);
z-index: 204;
overflow: hidden;
// Closed state
max-height: 0;
visibility: hidden;
transition: max-height 0.25s ease-out, visibility 0s 0.25s;
&[open] {
max-height: 5rem;
visibility: visible;
transition: max-height 0.25s ease-out, visibility 0s;
display: flex !important;
flex-direction: row;
gap: 1.5rem;
align-items: center;
padding: 0.4rem 1rem;
}
}
.kit-bag-section {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.kit-bag-label {
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(var(--secUser), 0.35);
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
}
.kit-bag-row {
display: flex;
flex-direction: row;
gap: 0.4rem;
}
.kit-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.3rem 0.4rem;
border: 0.1rem solid rgba(var(--terUser), 0.35);
border-radius: 0.4rem;
cursor: pointer;
min-width: 3rem;
transition: border-color 0.15s, box-shadow 0.15s;
i {
font-size: 1.1rem;
color: rgba(var(--terUser), 0.7);
}
.kit-card-label {
font-size: 0.5rem;
color: rgba(var(--secUser), 0.45);
text-align: center;
white-space: nowrap;
}
&:hover {
border-color: rgba(var(--terUser), 0.7);
}
&.selected {
border-color: rgba(var(--terUser), 1);
box-shadow:
0 0 0.4rem rgba(var(--terUser), 0.5),
0 0 1rem rgba(var(--terUser), 0.2)
;
i { color: rgba(var(--terUser), 1); }
}
}
.kit-bag-empty {
font-size: 0.7rem;
color: rgba(var(--secUser), 0.4);
}

View File

@@ -10,11 +10,49 @@ $gate-line: 2px;
min-height: 60vh; min-height: 60vh;
} }
.room-page .gear-btn, .room-page .gear-btn {
#id_room_menu {
z-index: 101; z-index: 101;
} }
#id_room_menu {
position: absolute;
bottom: 3.5rem;
right: 0.5rem;
z-index: 101;
background-color: rgba(var(--priUser), 0.95);
border: 0.15rem solid rgba(var(--secUser), 1);
box-shadow:
0 0 0.5rem rgba(var(--secUser), 0.75),
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25)
;
border-radius: 0.75rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
body:has(.gate-overlay) {
overflow: hidden;
// Pin gear controls to the visual viewport,
// bypassing iOS 100vh chrome-inclusion bug.
// Offset upward so gear btn clears the kit btn below it.
.room-page .gear-btn {
position: fixed;
bottom: 4.2rem;
right: 0.5rem;
z-index: 202;
}
#id_room_menu {
position: fixed;
bottom: 6.6rem;
right: 0.5rem;
z-index: 202;
}
}
.gate-overlay { .gate-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -24,20 +62,42 @@ $gate-line: 2px;
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
z-index: 100; z-index: 100;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
} }
.gate-modal { .gate-modal {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 2rem;
padding: 2rem; padding: 2rem;
border: 0.1rem solid rgba(var(--terUser), 0.5);
border-radius: 1rem; border-radius: 1rem;
background-color: rgba(var(--priUser), 1); background-color: rgba(var(--priUser), 1);
.gate-header { .gate-header {
text-align: center; text-align: center;
h1 { margin: 0 0 0.5rem; }
h1 {
font-size: 2rem;
color: rgba(var(--secUser), 0.6);
margin-bottom: 1rem;
text-align: justify;
text-align-last: center;
text-justify: inter-character;
text-transform: uppercase;
text-shadow:
1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left)
-0.125rem -0.125rem 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
;
span {
color: rgba(var(--quaUser), 0.6);
}
margin: 0 0 0.5rem;
}
.gate-status-wrap { .gate-status-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -46,6 +106,7 @@ $gate-line: 2px;
font-size: 0.75em; font-size: 0.75em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.15em; letter-spacing: 0.15em;
margin-bottom: 1rem;
.status-dots { .status-dots {
display: inline-flex; display: inline-flex;
@@ -72,6 +133,14 @@ $gate-line: 2px;
pointer-events: none; pointer-events: none;
} }
&.ready {
border-color: rgba(var(--terUser), 1);
box-shadow:
0 0 0.6rem rgba(var(--terUser), 0.6),
0 0 1.6rem rgba(var(--terUser), 0.25)
;
}
&.pending, &.pending,
&.claimed { &.claimed {
box-shadow: box-shadow:
@@ -244,15 +313,27 @@ $gate-line: 2px;
} }
} }
// Mobile: 2×3 grid, both rows left-to-right // Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop)
@media (max-width: 550px) { @media (max-width: 550px) {
.gate-modal .gate-slots { .gate-modal {
padding: 1.25rem 1.5rem;
.gate-header {
h1 { font-size: 1.5rem; }
.gate-status-wrap { margin-bottom: 0.5rem; }
}
.token-slot { min-width: 150px; }
.gate-slots {
display: grid; display: grid;
grid-template-columns: repeat(3, $gate-node); grid-template-columns: repeat(3, 52px);
grid-template-rows: repeat(2, $gate-node); grid-template-rows: repeat(2, 52px);
gap: $gate-gap; gap: 24px;
.gate-slot { .gate-slot {
width: 52px;
height: 52px;
&:nth-child(1) { grid-column: 1; grid-row: 1; } &:nth-child(1) { grid-column: 1; grid-row: 1; }
&:nth-child(2) { grid-column: 2; grid-row: 1; } &:nth-child(2) { grid-column: 2; grid-row: 1; }
&:nth-child(3) { grid-column: 3; grid-row: 1; } &:nth-child(3) { grid-column: 3; grid-row: 1; }
@@ -261,4 +342,56 @@ $gate-line: 2px;
&:nth-child(6) { grid-column: 3; grid-row: 2; } &:nth-child(6) { grid-column: 3; grid-row: 2; }
} }
} }
}
}
// Landscape mobile — aggressively scale down to fit short viewport
@media (orientation: landscape) and (max-width: 1023px) {
.room-page .gear-btn {
bottom: 3.5rem;
}
.gate-modal {
padding: 0.6rem 1.25rem;
.gate-header {
h1 { font-size: 1rem; margin: 0 0 0.25rem; }
.gate-status-wrap { font-size: 0.65em; margin-bottom: 0.35rem; }
}
.token-slot {
min-width: 130px;
.token-rails,
button.token-rails { padding: 0.4rem 0.35rem; }
.token-panel {
padding: 0.3rem 0.5rem;
.token-denomination { font-size: 1.1em; }
}
}
.gate-slots {
gap: 14px;
.gate-slot {
width: 40px;
height: 40px;
.slot-number { font-size: 0.6em; }
}
}
.form-container {
h3 { font-size: 0.85rem; margin: 0.25rem 0; }
form { gap: 0.35rem; }
.form-control-lg {
--_pad-v: 0.4rem;
font-size: 0.9rem;
}
}
}
} }

View File

@@ -6,6 +6,7 @@
@import 'gameboard'; @import 'gameboard';
@import 'palette-picker'; @import 'palette-picker';
@import 'room'; @import 'room';
@import 'game-kit';
@import 'wallet-tokens'; @import 'wallet-tokens';

View File

@@ -10,7 +10,7 @@
{% block content %} {% block content %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div id="id_dash_content"> <div id="id_dash_content" class="dashboard-page">
{% include "apps/applets/_partials/_gear.html" with menu_id="id_dash_applet_menu" %} {% include "apps/applets/_partials/_gear.html" with menu_id="id_dash_applet_menu" %}
{% include "apps/dashboard/_partials/_applets.html" %} {% include "apps/dashboard/_partials/_applets.html" %}
</div> </div>

View File

@@ -4,9 +4,19 @@
> >
<h2>Game Kit</h2> <h2>Game Kit</h2>
<div id="id_game_kit"> <div id="id_game_kit">
{% if pass_token %}
<div id="id_kit_pass_token" class="token">
<i class="fa-solid fa-clipboard"></i>
<div class="token-tooltip">
<h4>{{ pass_token.tooltip_name }}</h4>
<p>{{ pass_token.tooltip_description }}</p>
<p class="expiry">{{ pass_token.tooltip_expiry }}</p>
</div>
</div>
{% endif %}
{% if coin %} {% if coin %}
<div id="id_kit_coin_on_a_string" class="token"> <div id="id_kit_coin_on_a_string" class="token">
<i class="fa-solid fa-clover"></i> <i class="fa-solid fa-medal"></i>
<div class="token-tooltip"> <div class="token-tooltip">
<h4>{{ coin.tooltip_name }}</h4> <h4>{{ coin.tooltip_name }}</h4>
<p> <p>

View File

@@ -17,7 +17,7 @@
</div> </div>
</header> </header>
<div class="token-slot{% if can_drop %} active{% elif user_reserved_slot %} pending{% elif user_filled_slot %} claimed{% else %} locked{% endif %}"> <div class="token-slot{% if can_drop %} active{% elif user_reserved_slot %} pending{% elif user_filled_slot %} claimed{% elif token_depleted %} depleted{% else %} locked{% endif %}">
{% if can_drop %} {% if can_drop %}
<form method="POST" action="{% url 'epic:drop_token' room.id %}" style="display:contents"> <form method="POST" action="{% url 'epic:drop_token' room.id %}" style="display:contents">
{% csrf_token %} {% csrf_token %}
@@ -45,7 +45,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="gate-slots"> <div class="gate-slots row">
{% for slot in slots %} {% for slot in slots %}
<div <div
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% elif slot.status == 'RESERVED' %} reserved{% endif %}" class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% elif slot.status == 'RESERVED' %} reserved{% endif %}"
@@ -72,11 +72,14 @@
</div> </div>
{% if request.user == room.owner %} {% if request.user == room.owner %}
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}"> <div class="form-container">
<h3>Invite Friend</h3>
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}" style="display:flex; gap:0.5rem; align-items:center;">
{% csrf_token %} {% csrf_token %}
<input type="email" name="invitee_email" id="id_invite_email" class="form-control form-control-lg" placeholder="friend@example.com" hx-preserve> <input type="email" name="invitee_email" id="id_invite_email" class="form-control form-control-lg" placeholder="friend@example.com" style="flex:1; min-width:0;" hx-preserve>
<button type="submit" id="id_invite_btn" class="btn btn-primary btn-xl">Invite</button> <button type="submit" id="id_invite_btn" class="btn btn-confirm">OK</button>
</form> </form>
</div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -4,9 +4,18 @@
> >
<h2>Tokens</h2> <h2>Tokens</h2>
<div class="token-row"> <div class="token-row">
{% if coin %} {% if pass_token %}
<div id="id_pass_token" class="token">
<i class="fa-solid fa-clipboard"></i>
<div class="token-tooltip">
<h4>{{ pass_token.tooltip_name }}</h4>
<p>{{ pass_token.tooltip_description }}</p>
<p class="expiry">{{ pass_token.tooltip_expiry }}</p>
</div>
</div>
{% elif coin %}
<div id="id_coin_on_a_string" class="token"> <div id="id_coin_on_a_string" class="token">
<i class="fa-solid fa-clover"></i> <i class="fa-solid fa-medal"></i>
<div class="token-tooltip"> <div class="token-tooltip">
<h4>{{ coin.tooltip_name }}</h4> <h4>{{ coin.tooltip_name }}</h4>
<p>{{ coin.tooltip_description }}</p> <p>{{ coin.tooltip_description }}</p>
@@ -29,6 +38,15 @@
<p class="expiry">{{ token.tooltip_expiry }}</p> <p class="expiry">{{ token.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% empty %}
<div id="id_free_token_empty" class="token token--empty">
<i class="fa-solid fa-coins"></i>
<div class="token-tooltip">
<h4>Free Token</h4>
<p>0 owned</p>
<p class="expiry">find one around</p>
</div>
</div>
{% endfor %} {% endfor %}
{% for token in tithe_tokens %} {% for token in tithe_tokens %}
<div id="id_tithe_token_{{ forloop.counter0 }}" class="token"> <div id="id_tithe_token_{{ forloop.counter0 }}" class="token">

View File

@@ -0,0 +1,48 @@
{% if tokens %}
<div class="kit-bag-section">
<span class="kit-bag-label">Trinkets</span>
<div class="kit-bag-row">
{% for token in tokens %}
{% if token.token_type == "coin" or token.token_type == "pass" %}
<div
class="kit-card"
draggable="true"
data-token-id="{{ token.id }}"
data-token-type="{{ token.token_type }}"
>
{% if token.token_type == "coin" %}
<i class="fa-solid fa-medal"></i>
{% else %}
<i class="fa-solid fa-clipboard"></i>
{% endif %}
<span class="kit-card-label">{{ token.tooltip_name }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
<div class="kit-bag-section">
<span class="kit-bag-label">Tokens</span>
<div class="kit-bag-row">
{% for token in tokens %}
{% if token.token_type == "Free" or token.token_type == "tithe" %}
<div
class="kit-card"
draggable="true"
data-token-id="{{ token.id }}"
data-token-type="{{ token.token_type }}"
>
{% if token.token_type == "Free" %}
<i class="fa-solid fa-coins"></i>
{% else %}
<i class="fa-solid fa-piggy-bank"></i>
{% endif %}
<span class="kit-card-label">{{ token.tooltip_name }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
<p class="kit-bag-empty">Kit bag empty.</p>
{% endif %}

View File

@@ -50,10 +50,18 @@
{% include "core/_partials/_footer.html" %} {% include "core/_partials/_footer.html" %}
{% if user.is_authenticated %}
<button id="id_kit_btn" data-kit-url="{% url 'kit_bag' %}" aria-label="Open Kit Bag">
<i class="fa-solid fa-briefcase"></i>
</button>
{% endif %}
<dialog id="id_kit_bag_dialog"></dialog>
{% block scripts %} {% block scripts %}
{% endblock scripts %} {% endblock scripts %}
<script src="{% static "vendor/htmx.min.js" %}"></script> <script src="{% static "vendor/htmx.min.js" %}"></script>
<script src="{% static "apps/scripts/applets.js" %}"></script> <script src="{% static "apps/scripts/applets.js" %}"></script>
<script src="{% static "apps/scripts/game-kit.js" %}"></script>
<script> <script>
document.body.addEventListener('htmx:configRequest', function(evt) { document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken'); evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');