new apps.epic app migrations for token expiration & cooldown; reject token renamed to return token everywhere; new mapps.epic.models & .views for expiration & cooldown; new apps.dash.views to manage stacking of like Token types not just in the kit bag but in the Gameboard's Game Kit applet & in the Dashwallet's Tokens applet; Free Tokens now display correctly in kit bag; apps.lyric.admin now ensures superuser cannot grant Free Tokens without an expiration date; corresponding tests in .tests.integrated.test_admin.TokenAdminFormTest; screendumps occurring for every test, regardless of passfail status, after one fail fixed in FTs.base; FTs.test_gatekeeper.GameKitInsertTest.test_free_token_insert_via_kit_consumed_on_confirm, for test purposes only, ensures starting Free Token deleted before fresh one assigned w. full 7d expiration battery
This commit is contained in:
@@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Max, Q
|
||||
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
@@ -148,8 +149,14 @@ def wallet(request):
|
||||
"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(),
|
||||
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
||||
"free_tokens": list(request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at")),
|
||||
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
||||
"free_count": request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).count(),
|
||||
"tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(),
|
||||
"applets": applet_context(request.user, "wallet"),
|
||||
"page_class": "page-wallet",
|
||||
})
|
||||
@@ -158,7 +165,18 @@ def wallet(request):
|
||||
@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})
|
||||
free_tokens = sorted(
|
||||
[t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()],
|
||||
key=lambda t: t.expires_at,
|
||||
)
|
||||
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
||||
return render(request, "core/_partials/_kit_bag_panel.html", {
|
||||
"tokens": tokens,
|
||||
"free_token": free_tokens[0] if free_tokens else None,
|
||||
"free_count": len(free_tokens),
|
||||
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
|
||||
"tithe_count": len(tithe_tokens),
|
||||
})
|
||||
|
||||
@login_required(login_url="/")
|
||||
def toggle_wallet_applets(request):
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0004_alter_room_gate_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='gateslot',
|
||||
name='debited_token_type',
|
||||
field=models.CharField(max_length=8, null=True, blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gateslot',
|
||||
name='debited_token_expires_at',
|
||||
field=models.DateTimeField(null=True, blank=True),
|
||||
),
|
||||
]
|
||||
@@ -65,6 +65,8 @@ class GateSlot(models.Model):
|
||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY)
|
||||
reserved_at = models.DateTimeField(null=True, blank=True)
|
||||
filled_at = models.DateTimeField(null=True, blank=True)
|
||||
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
|
||||
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
class RoomInvite(models.Model):
|
||||
@@ -111,12 +113,14 @@ def select_token(user):
|
||||
|
||||
|
||||
def debit_token(user, slot, token):
|
||||
slot.debited_token_type = token.token_type
|
||||
if token.token_type == Token.COIN:
|
||||
token.current_room = slot.room
|
||||
period = slot.room.renewal_period or timedelta(days=7)
|
||||
token.next_ready_at = timezone.now() + period
|
||||
token.save()
|
||||
elif token.token_type != Token.PASS:
|
||||
slot.debited_token_expires_at = token.expires_at
|
||||
token.delete()
|
||||
slot.gamer = user
|
||||
slot.status = GateSlot.FILLED
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import timedelta
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@@ -189,7 +190,7 @@ class ConfirmTokenViewTest(TestCase):
|
||||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||||
|
||||
|
||||
class RejectTokenViewTest(TestCase):
|
||||
class ReturnTokenViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.gamer = User.objects.create(email="gamer@test.io")
|
||||
self.client.force_login(self.gamer)
|
||||
@@ -201,33 +202,73 @@ class RejectTokenViewTest(TestCase):
|
||||
self.slot.reserved_at = timezone.now()
|
||||
self.slot.save()
|
||||
|
||||
def test_reject_clears_reserved_slot(self):
|
||||
def test_return_clears_reserved_slot(self):
|
||||
self.client.post(
|
||||
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
|
||||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||
)
|
||||
self.slot.refresh_from_db()
|
||||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||||
self.assertIsNone(self.slot.gamer)
|
||||
self.assertIsNone(self.slot.reserved_at)
|
||||
|
||||
def test_reject_after_confirm_clears_filled_slot(self):
|
||||
def test_return_after_confirm_clears_filled_slot(self):
|
||||
self.slot.status = GateSlot.FILLED
|
||||
self.slot.save()
|
||||
self.client.post(
|
||||
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
|
||||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||
)
|
||||
self.slot.refresh_from_db()
|
||||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||||
self.assertIsNone(self.slot.gamer)
|
||||
|
||||
def test_reject_redirects_to_gatekeeper(self):
|
||||
def test_return_redirects_to_gatekeeper(self):
|
||||
response = self.client.post(
|
||||
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
|
||||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||
)
|
||||
self.assertRedirects(
|
||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||
)
|
||||
|
||||
def test_return_restores_coin_token(self):
|
||||
coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
|
||||
coin.current_room = self.room
|
||||
coin.next_ready_at = timezone.now() + timedelta(days=7)
|
||||
coin.save()
|
||||
self.slot.status = GateSlot.FILLED
|
||||
self.slot.debited_token_type = Token.COIN
|
||||
self.slot.save()
|
||||
self.client.post(
|
||||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||
)
|
||||
coin.refresh_from_db()
|
||||
self.assertIsNone(coin.current_room)
|
||||
self.assertIsNone(coin.next_ready_at)
|
||||
|
||||
def test_return_restores_free_token(self):
|
||||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
|
||||
expires = timezone.now() + timedelta(days=3)
|
||||
self.slot.status = GateSlot.FILLED
|
||||
self.slot.debited_token_type = Token.FREE
|
||||
self.slot.debited_token_expires_at = expires
|
||||
self.slot.save()
|
||||
self.client.post(
|
||||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||
)
|
||||
restored = Token.objects.filter(user=self.gamer, token_type=Token.FREE).first()
|
||||
self.assertIsNotNone(restored)
|
||||
self.assertEqual(restored.expires_at, expires)
|
||||
|
||||
def test_return_restores_tithe_token(self):
|
||||
self.slot.status = GateSlot.FILLED
|
||||
self.slot.debited_token_type = Token.TITHE
|
||||
self.slot.save()
|
||||
self.client.post(
|
||||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||
)
|
||||
self.assertTrue(
|
||||
Token.objects.filter(user=self.gamer, token_type=Token.TITHE).exists()
|
||||
)
|
||||
|
||||
|
||||
class DropTokenAvailabilityViewTest(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -9,7 +9,7 @@ urlpatterns = [
|
||||
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
||||
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
|
||||
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
|
||||
path('room/<uuid:room_id>/gate/reject_token', views.reject_token, name='reject_token'),
|
||||
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
|
||||
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
|
||||
from apps.lyric.models import Token
|
||||
|
||||
|
||||
RESERVE_TIMEOUT = timedelta(seconds=60)
|
||||
@@ -117,7 +118,7 @@ def confirm_token(request, room_id):
|
||||
|
||||
|
||||
@login_required
|
||||
def reject_token(request, room_id):
|
||||
def return_token(request, room_id):
|
||||
if request.method == "POST":
|
||||
room = Room.objects.get(id=room_id)
|
||||
slot = room.gate_slots.filter(
|
||||
@@ -125,10 +126,28 @@ def reject_token(request, room_id):
|
||||
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
|
||||
).first()
|
||||
if slot:
|
||||
if slot.status == GateSlot.FILLED:
|
||||
if slot.debited_token_type == Token.COIN:
|
||||
coin = request.user.tokens.filter(
|
||||
token_type=Token.COIN, current_room=room
|
||||
).first()
|
||||
if coin:
|
||||
coin.current_room = None
|
||||
coin.next_ready_at = None
|
||||
coin.save()
|
||||
elif slot.debited_token_type in (Token.FREE, Token.TITHE):
|
||||
Token.objects.create(
|
||||
user=request.user,
|
||||
token_type=slot.debited_token_type,
|
||||
expires_at=slot.debited_token_expires_at,
|
||||
)
|
||||
request.session.pop("kit_token_id", None)
|
||||
slot.gamer = None
|
||||
slot.status = GateSlot.EMPTY
|
||||
slot.reserved_at = None
|
||||
slot.filled_at = None
|
||||
slot.debited_token_type = None
|
||||
slot.debited_token_expires_at = None
|
||||
slot.save()
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.applets.utils import applet_context
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
@@ -19,12 +20,15 @@ GAMEBOARD_APPLET_ORDER = [
|
||||
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()
|
||||
free_tokens = list(request.user.tokens.filter(token_type=Token.FREE))
|
||||
free_tokens = list(request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at"))
|
||||
return render(
|
||||
request, "apps/gameboard/gameboard.html", {
|
||||
"pass_token": pass_token,
|
||||
"coin": coin,
|
||||
"free_tokens": free_tokens,
|
||||
"free_count": len(free_tokens),
|
||||
"applets": applet_context(request.user, "gameboard"),
|
||||
"page_class": "page-gameboard",
|
||||
"my_games": Room.objects.filter(
|
||||
@@ -49,7 +53,12 @@ def toggle_game_applets(request):
|
||||
"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(),
|
||||
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
||||
"free_tokens": list(request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at")),
|
||||
"free_count": request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).count(),
|
||||
"my_games": Room.objects.filter(
|
||||
Q(owner=request.user) |
|
||||
Q(gate_slots__gamer=request.user) |
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import LoginToken, Token, User
|
||||
@@ -7,6 +8,23 @@ class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ["email"]
|
||||
search_fields = ["email"]
|
||||
|
||||
|
||||
class TokenAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Token
|
||||
fields = "__all__"
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if cleaned_data.get("token_type") == Token.FREE and not cleaned_data.get("expires_at"):
|
||||
raise forms.ValidationError("Free Tokens must have an expiration date.")
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class TokenAdmin(admin.ModelAdmin):
|
||||
form = TokenAdminForm
|
||||
|
||||
|
||||
admin.site.register(User, UserAdmin)
|
||||
admin.site.register(LoginToken)
|
||||
admin.site.register(Token)
|
||||
admin.site.register(Token, TokenAdmin)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.lyric.models import User
|
||||
from apps.lyric.admin import TokenAdminForm
|
||||
from apps.lyric.models import Token, User
|
||||
|
||||
|
||||
class UserAdminTest(TestCase):
|
||||
@@ -23,3 +25,30 @@ class UserAdminTest(TestCase):
|
||||
response = self.client.get("/admin/lyric/user/?q=admin")
|
||||
self.assertContains(response, "admin@example.com")
|
||||
self.assertNotContains(response, "other@example.com")
|
||||
|
||||
|
||||
class TokenAdminFormTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="gamer@example.com")
|
||||
|
||||
def _form(self, token_type, expires_at=None):
|
||||
return TokenAdminForm(data={
|
||||
"user": self.user.pk,
|
||||
"token_type": token_type,
|
||||
"expires_at": expires_at or "",
|
||||
})
|
||||
|
||||
def test_free_token_without_expires_at_is_invalid(self):
|
||||
form = self._form(Token.FREE)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn("Free Tokens must have an expiration date", str(form.errors))
|
||||
|
||||
def test_free_token_with_expires_at_is_valid(self):
|
||||
form = self._form(Token.FREE, expires_at=timezone.now())
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_other_token_types_do_not_require_expires_at(self):
|
||||
for token_type in (Token.COIN, Token.TITHE, Token.PASS):
|
||||
with self.subTest(token_type=token_type):
|
||||
form = self._form(token_type)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
@@ -59,7 +59,10 @@ class FunctionalTest(StaticLiveServerTestCase):
|
||||
super().tearDown()
|
||||
|
||||
def _test_has_failed(self):
|
||||
return self._outcome.result.failures or self._outcome.result.errors
|
||||
return any(
|
||||
failure[0] == self
|
||||
for failure in self._outcome.result.failures + self._outcome.result.errors
|
||||
)
|
||||
|
||||
def take_screenshot(self):
|
||||
path = SCREEN_DUMP_LOCATION / self._get_filename("png")
|
||||
|
||||
@@ -314,15 +314,15 @@ class CoinSlotTest(FunctionalTest):
|
||||
slot.refresh_from_db()
|
||||
self.assertEqual(slot.status, GateSlot.FILLED)
|
||||
|
||||
def test_gamer_can_reject_pending_token(self):
|
||||
# Drop then reject via Push to Reject → slot remains empty
|
||||
def test_gamer_can_return_pending_token(self):
|
||||
# Drop then return via Push to Return → slot remains empty
|
||||
self.browser.get(self.gate_url)
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
|
||||
).click()
|
||||
# Push to Reject appears in coin slot
|
||||
# Push to Return appears in coin slot
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-reject-btn")
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-return-btn")
|
||||
).click()
|
||||
# Slot 1 still empty; coin slot active again
|
||||
self.wait_for(
|
||||
@@ -535,6 +535,7 @@ class GameKitInsertTest(FunctionalTest):
|
||||
self.assertEqual(self.browser.current_url, self.gate_url)
|
||||
|
||||
def test_free_token_insert_via_kit_consumed_on_confirm(self):
|
||||
self.gamer.tokens.filter(token_type=Token.FREE).delete()
|
||||
token = Token.objects.create(
|
||||
user=self.gamer,
|
||||
token_type=Token.FREE,
|
||||
|
||||
@@ -32,6 +32,11 @@ $gate-line: 2px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// Scroll-lock when gate is open. Uses html (not body) to avoid CSS overflow
|
||||
// propagation quirk on Linux headless Firefox where body overflow:hidden can
|
||||
// disrupt pointer events on position:fixed descendants.
|
||||
// NOTE: may be superfluous — root cause of CI kit-btn failures turned out to be
|
||||
// game-kit.js missing from git (was in gitignored STATIC_ROOT only).
|
||||
html:has(.gate-overlay) {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -68,6 +73,9 @@ body:has(.gate-overlay) {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
// Prevents backdrop from intercepting clicks on position:fixed elements
|
||||
// (e.g. #id_kit_btn) in Linux headless Firefox.
|
||||
// NOTE: may be superfluous — see html:has comment above.
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -152,7 +160,7 @@ body:has(.gate-overlay) {
|
||||
0 0 0.6rem rgba(var(--terUser), 0.5),
|
||||
0 0 1.4rem rgba(var(--terUser), 0.2),
|
||||
;
|
||||
.token-reject-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); }
|
||||
.token-return-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); }
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--terUser), 1);
|
||||
@@ -195,7 +203,7 @@ body:has(.gate-overlay) {
|
||||
}
|
||||
}
|
||||
|
||||
.token-reject-btn {
|
||||
.token-return-btn {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
@@ -232,7 +240,7 @@ body:has(.gate-overlay) {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.token-reject-label {
|
||||
.token-return-label {
|
||||
font-size: 0.55em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
|
||||
@@ -32,21 +32,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for token in free_tokens %}
|
||||
<div id="id_kit_free_token_{{ forloop.counter0 }}" class="token">
|
||||
{% if free_tokens %}
|
||||
{% with free_tokens.0 as token %}
|
||||
<div id="id_kit_free_token" class="token">
|
||||
<i class="fa-solid fa-coins"></i>
|
||||
<div class="token-tooltip">
|
||||
<h4>{{ token.tooltip_name }}</h4>
|
||||
<p>
|
||||
{{ token.tooltip_description }}
|
||||
</p>
|
||||
{% if token.tooltip_shoptalk %}
|
||||
<small><em>{{ token.tooltip_shoptalk }}</em></small>
|
||||
{% endif %}
|
||||
<h4>{{ token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
|
||||
<p>{{ token.tooltip_description }}</p>
|
||||
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
<div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div>
|
||||
<div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
|
||||
</div>
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
<div class="token-panel">
|
||||
<div class="token-denomination">1</div>
|
||||
<span class="token-insert-label">INSERT TOKEN TO PLAY</span>
|
||||
<span class="token-reject-label">PUSH TO REJECT</span>
|
||||
<span class="token-return-label">PUSH TO RETURN</span>
|
||||
</div>
|
||||
{% if user_can_reject %}
|
||||
<form method="POST" action="{% url 'epic:reject_token' room.id %}" style="display:contents">
|
||||
<form method="POST" action="{% url 'epic:return_token' room.id %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="token-reject-btn" aria-label="Push to reject"></button>
|
||||
<button type="submit" class="token-return-btn" aria-label="Push to return"></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -26,19 +26,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for token in free_tokens %}
|
||||
<div id="id_free_token_{{ forloop.counter0 }}" class="token">
|
||||
{% if free_tokens %}
|
||||
{% with free_tokens.0 as token %}
|
||||
<div id="id_free_token" class="token">
|
||||
<i class="fa-solid fa-coins"></i>
|
||||
<div class="token-tooltip">
|
||||
<h4>{{ token.tooltip_name }}</h4>
|
||||
<h4>{{ token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
|
||||
<p>{{ token.tooltip_description }}</p>
|
||||
{% if token.tooltip_shoptalk %}
|
||||
<small><em>{{ token.tooltip_shoptalk }}</em></small>
|
||||
{% endif %}
|
||||
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div id="id_free_token_empty" class="token token--empty">
|
||||
<i class="fa-solid fa-coins"></i>
|
||||
<div class="token-tooltip">
|
||||
@@ -47,20 +46,19 @@
|
||||
<p class="expiry">find one around</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for token in tithe_tokens %}
|
||||
<div id="id_tithe_token_{{ forloop.counter0 }}" class="token">
|
||||
{% endif %}
|
||||
{% if tithe_tokens %}
|
||||
{% with tithe_tokens.0 as token %}
|
||||
<div id="id_tithe_token" class="token">
|
||||
<i class="fa-solid fa-piggy-bank"></i>
|
||||
<div class="token-tooltip">
|
||||
<h4>{{ token.tooltip_name }}</h4>
|
||||
<h4>{{ token.tooltip_name }}{% if tithe_count > 1 %} <span class="token-count">(×{{ tithe_count }})</span>{% endif %}</h4>
|
||||
<p>{{ token.tooltip_description }}</p>
|
||||
{% if token.tooltip_shoptalk %}
|
||||
<small><em>{{ token.tooltip_shoptalk }}</em></small>
|
||||
{% endif %}
|
||||
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div id="id_tithe_token_empty" class="token token--empty">
|
||||
<i class="fa-solid fa-piggy-bank"></i>
|
||||
<div class="token-tooltip">
|
||||
@@ -69,6 +67,6 @@
|
||||
<p class="expiry">purchase one above</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -24,23 +24,32 @@
|
||||
<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" %}
|
||||
{% if free_token %}
|
||||
<div
|
||||
class="kit-card"
|
||||
draggable="true"
|
||||
data-token-id="{{ token.id }}"
|
||||
data-token-type="{{ token.token_type }}"
|
||||
data-token-id="{{ free_token.id }}"
|
||||
data-token-type="{{ free_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>
|
||||
<span class="kit-card-label">
|
||||
{{ free_token.tooltip_name }}{% if free_count > 1 %} <span class="kit-card-count">(×{{ free_count }})</span>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tithe_token %}
|
||||
<div
|
||||
class="kit-card"
|
||||
draggable="true"
|
||||
data-token-id="{{ tithe_token.id }}"
|
||||
data-token-type="{{ tithe_token.token_type }}"
|
||||
>
|
||||
<i class="fa-solid fa-piggy-bank"></i>
|
||||
<span class="kit-card-label">
|
||||
{{ tithe_token.tooltip_name }}{% if tithe_count > 1 %} <span class="kit-card-count">(×{{ tithe_count }})</span>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
Reference in New Issue
Block a user