my-sea spectator Phase B: seat-2C occupancy + visitor token gate + one-shot seated glow + gear BYE — TDD
Phase B of the my-sea invite → spectator → voice blueprint. An ACCEPTED invitee can watch the owner's my-sea read-only, deposit a token to occupy seat 2C (opening a 24h voice window for Phase C), and BYE out. Owner's my_sea.html is left structurally intact — the spectator gets a dedicated, simpler my_sea_visit.html; the read-only draw reuses the existing `latest_draw_slots` payload (no picker surgery). - B1: my_sea_visit(owner_id) spectator view — 403 unless an ACCEPTED SeaInvite(owner, request.user); owner bounced to their own my_sea. Context forces owner-only controls off (sea_btn_active=False, read_only=True); renders the table hex (1C owner / 2C visitor) + owner draw read-only. - B2: visitor gate — my_sea_visit_gate reuses my_sea_gate.html w. a spectator branch (titles the OWNER's Sea, INSERT posts to the visitor endpoint, bud-panel suppressed, gear NVM→visit + BYE). Single-step my_sea_visit_insert_token selects+debits the visitor's token (same priority chain) and records token_deposited_at + a 24h voice_until on the SeaInvite → seat 2C present. Center btn flips GATE VIEW → VIEW DRAW. - B3: spectator gear BYE — my_sea_visit_leave sets status=LEFT, left_at, clears voice_until (frees 2C, ends voice), redirects /gameboard/. _my_sea_gear.html gains a `leave_url`-gated BYE below NVM (owner pages pass no leave_url, so unchanged). - B-seat: one-shot "seated" glow per user-spec 2026-05-27 — new shared apps/gameboard/my-sea-seats.js: on first view (localStorage-gated by a per-occupancy data-seat-token) an occupied seat flares --terUser + --ninUser glow ~1.5s then settles to full-opacity --secUser (.fa-ban already swapped to .fa-circle-check). _room.scss adds .seated / .seat-just-seated + the my-sea-seat-flare keyframes (mirrors the room's .active→.role-confirmed handoff). Wired on BOTH the spectator page (load) and the owner page (load + on the FREE DRAW seat-1 transition). MySeaSeatsSpec.js Jasmine spec covers the gating + timed class removal. - B5: MySeaSpectatorFlowTest FT — accept → visit → GATE VIEW → deposit → VIEW DRAW + seat 2C seated. URLs: my-sea/visit/<uuid:owner_id>/ (+ /gate/, /insert, /leave). 470 IT/UT green; spectator FT + full Jasmine suite green. Phase C (WebRTC mesh voice + coturn droplet) next — the 24h voice_until window set here drives it. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
53
src/apps/gameboard/static/apps/gameboard/my-sea-seats.js
Normal file
53
src/apps/gameboard/static/apps/gameboard/my-sea-seats.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// my-sea-seats.js — one-shot "seated" animation for the my-sea table hex.
|
||||
//
|
||||
// When a seat is occupied (owner draws → seat 1C; visitor deposits a token →
|
||||
// seat 2C), the FIRST time a given viewer sees that occupancy the seat plays
|
||||
// a one-shot glow: chair fills --terUser + glows --ninUsers for ~1.5s, then
|
||||
// settles back to --secUser at FULL opacity (the steady `.seated` look), with
|
||||
// `.fa-ban` already swapped for `.fa-circle-check` server-side. "First time"
|
||||
// is tracked per-browser in localStorage, keyed on a stable per-occupancy
|
||||
// token (`data-seat-token`) so a fresh draw / fresh invite re-animates but a
|
||||
// reload does not. (Spec: user 2026-05-27.)
|
||||
(function () {
|
||||
'use strict';
|
||||
var STORE_PREFIX = 'mysea-seat-seen:';
|
||||
var GLOW_MS = 1500;
|
||||
|
||||
function alreadySeen(token) {
|
||||
try {
|
||||
return window.localStorage.getItem(STORE_PREFIX + token) === '1';
|
||||
} catch (e) {
|
||||
return false; // private mode / storage disabled → always animate
|
||||
}
|
||||
}
|
||||
|
||||
function markSeen(token) {
|
||||
try { window.localStorage.setItem(STORE_PREFIX + token, '1'); }
|
||||
catch (e) { /* no-op */ }
|
||||
}
|
||||
|
||||
// Play the one-shot glow on a `.table-seat` element unless this viewer has
|
||||
// already seen this exact occupancy. Safe to call on a seat with no token
|
||||
// (always animates, never persists).
|
||||
function playSeatGlow(seat) {
|
||||
if (!seat) return;
|
||||
var token = seat.getAttribute('data-seat-token');
|
||||
if (token && alreadySeen(token)) return;
|
||||
seat.classList.add('seat-just-seated');
|
||||
window.setTimeout(function () {
|
||||
seat.classList.remove('seat-just-seated');
|
||||
}, GLOW_MS);
|
||||
if (token) markSeen(token);
|
||||
}
|
||||
|
||||
// Exposed so the owner's FREE DRAW flow can fire the same glow the moment
|
||||
// seat 1C transitions to `.seated` client-side (my_sea.html).
|
||||
window.playSeatGlow = playSeatGlow;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var seats = document.querySelectorAll('.table-seat.seated[data-seat-token]');
|
||||
Array.prototype.forEach.call(seats, function (seat) {
|
||||
playSeatGlow(seat);
|
||||
});
|
||||
});
|
||||
}());
|
||||
175
src/apps/gameboard/tests/integrated/test_sea_visit.py
Normal file
175
src/apps/gameboard/tests/integrated/test_sea_visit.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""ITs for the my-sea spectator (invitee) views — Phase B of
|
||||
[[my-sea-invite-voice-blueprint]].
|
||||
|
||||
An ACCEPTED invitee can VISIT the owner's my-sea as a read-only spectator,
|
||||
deposit a token at a visitor gate to occupy seat 2C (+ open a 24h voice
|
||||
window), and BYE out (freeing the seat). All access is gated on an ACCEPTED
|
||||
SeaInvite for (owner, request.user).
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.gameboard.models import MySeaDraw, SeaInvite
|
||||
from apps.lyric.models import Token, User
|
||||
|
||||
|
||||
def _owner_with_sig(email="owner@test.io", username="discoman"):
|
||||
owner = User.objects.create(email=email, username=username)
|
||||
# A sig id is required to build a MySeaDraw; any int is fine for the
|
||||
# spectator read-only path (no card lookup asserted here).
|
||||
return owner
|
||||
|
||||
|
||||
class MySeaVisitGuardTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = _owner_with_sig()
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||||
)
|
||||
self.url = reverse("my_sea_visit", args=[self.owner.id])
|
||||
|
||||
def test_accepted_invitee_can_visit(self):
|
||||
self.client.force_login(self.bud)
|
||||
self.assertEqual(self.client.get(self.url).status_code, 200)
|
||||
|
||||
def test_non_invitee_is_forbidden(self):
|
||||
stranger = User.objects.create(email="x@test.io", username="x")
|
||||
self.client.force_login(stranger)
|
||||
self.assertEqual(self.client.get(self.url).status_code, 403)
|
||||
|
||||
def test_pending_invitee_is_forbidden(self):
|
||||
self.invite.status = SeaInvite.PENDING
|
||||
self.invite.save()
|
||||
self.client.force_login(self.bud)
|
||||
self.assertEqual(self.client.get(self.url).status_code, 403)
|
||||
|
||||
def test_left_invitee_is_forbidden(self):
|
||||
self.invite.status = SeaInvite.LEFT
|
||||
self.invite.left_at = timezone.now()
|
||||
self.invite.save()
|
||||
self.client.force_login(self.bud)
|
||||
self.assertEqual(self.client.get(self.url).status_code, 403)
|
||||
|
||||
def test_owner_visiting_own_sea_redirects_to_my_sea(self):
|
||||
self.client.force_login(self.owner)
|
||||
resp = self.client.get(self.url)
|
||||
self.assertRedirects(resp, reverse("my_sea"), fetch_redirect_response=False)
|
||||
|
||||
|
||||
class MySeaVisitContextTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = _owner_with_sig()
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||||
)
|
||||
# Owner has drawn a hand → seat 1C is occupied + cards exist to view.
|
||||
MySeaDraw.objects.create(
|
||||
user=self.owner, spread="situation-action-outcome",
|
||||
significator_id=1, hand=[
|
||||
{"position": "lay", "card_id": 1, "reversed": False,
|
||||
"polarity": "gravity"},
|
||||
],
|
||||
)
|
||||
self.client.force_login(self.bud)
|
||||
self.url = reverse("my_sea_visit", args=[self.owner.id])
|
||||
|
||||
def test_spectator_context_flags(self):
|
||||
ctx = self.client.get(self.url).context
|
||||
self.assertTrue(ctx["spectator"])
|
||||
self.assertFalse(ctx["is_owner"])
|
||||
self.assertEqual(ctx["owner"], self.owner)
|
||||
self.assertEqual(ctx["sea_invite"], self.invite)
|
||||
# Owner controls forced off on the spectator surface.
|
||||
self.assertFalse(ctx["sea_btn_active"])
|
||||
|
||||
def test_not_present_shows_gate_view(self):
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertIn("GATE", content)
|
||||
self.assertIn(reverse("my_sea_visit_gate", args=[self.owner.id]), content)
|
||||
|
||||
def test_present_shows_view_draw_and_seat2(self):
|
||||
self.invite.token_deposited_at = timezone.now()
|
||||
self.invite.voice_until = timezone.now() + timedelta(hours=24)
|
||||
self.invite.save()
|
||||
ctx = self.client.get(self.url).context
|
||||
self.assertTrue(ctx["sea_invite"].is_present)
|
||||
self.assertTrue(ctx["seat2_present"])
|
||||
self.assertIn("VIEW", self.client.get(self.url).content.decode())
|
||||
|
||||
|
||||
class MySeaVisitInsertTokenTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = _owner_with_sig()
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||||
)
|
||||
self.url = reverse("my_sea_visit_insert_token", args=[self.owner.id])
|
||||
|
||||
def test_deposit_marks_present_and_opens_voice_window(self):
|
||||
self.client.force_login(self.bud)
|
||||
resp = self.client.post(self.url)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertIsNotNone(self.invite.token_deposited_at)
|
||||
self.assertIsNotNone(self.invite.voice_until)
|
||||
self.assertTrue(self.invite.voice_active)
|
||||
self.assertTrue(self.invite.is_present)
|
||||
self.assertRedirects(
|
||||
resp, reverse("my_sea_visit", args=[self.owner.id]),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
def test_non_invitee_cannot_deposit(self):
|
||||
stranger = User.objects.create(email="x@test.io", username="x")
|
||||
self.client.force_login(stranger)
|
||||
self.assertEqual(self.client.post(self.url).status_code, 403)
|
||||
|
||||
def test_no_token_does_not_mark_present(self):
|
||||
# Strip the visitor of every usable token.
|
||||
self.bud.equipped_trinket = None
|
||||
self.bud.save(update_fields=["equipped_trinket"])
|
||||
Token.objects.filter(user=self.bud).delete()
|
||||
self.client.force_login(self.bud)
|
||||
self.client.post(self.url)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertIsNone(self.invite.token_deposited_at)
|
||||
self.assertFalse(self.invite.is_present)
|
||||
|
||||
|
||||
class MySeaVisitLeaveTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = _owner_with_sig()
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||||
token_deposited_at=timezone.now(),
|
||||
voice_until=timezone.now() + timedelta(hours=24),
|
||||
)
|
||||
self.url = reverse("my_sea_visit_leave", args=[self.owner.id])
|
||||
|
||||
def test_leave_sets_left_and_kills_voice(self):
|
||||
self.client.force_login(self.bud)
|
||||
resp = self.client.post(self.url)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.LEFT)
|
||||
self.assertIsNotNone(self.invite.left_at)
|
||||
self.assertIsNone(self.invite.voice_until)
|
||||
self.assertFalse(self.invite.is_present)
|
||||
self.assertRedirects(
|
||||
resp, reverse("gameboard"), fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
def test_non_invitee_cannot_leave(self):
|
||||
stranger = User.objects.create(email="x@test.io", username="x")
|
||||
self.client.force_login(stranger)
|
||||
self.assertEqual(self.client.post(self.url).status_code, 403)
|
||||
@@ -25,6 +25,13 @@ urlpatterns = [
|
||||
name='my_sea_invite_accept'),
|
||||
path('my-sea/invite/decline/<int:invite_id>', views.my_sea_invite_decline,
|
||||
name='my_sea_invite_decline'),
|
||||
path('my-sea/visit/<uuid:owner_id>/', views.my_sea_visit, name='my_sea_visit'),
|
||||
path('my-sea/visit/<uuid:owner_id>/gate/', views.my_sea_visit_gate,
|
||||
name='my_sea_visit_gate'),
|
||||
path('my-sea/visit/<uuid:owner_id>/insert', views.my_sea_visit_insert_token,
|
||||
name='my_sea_visit_insert_token'),
|
||||
path('my-sea/visit/<uuid:owner_id>/leave', views.my_sea_visit_leave,
|
||||
name='my_sea_visit_leave'),
|
||||
path('my-sea/brief/free-draw/dismiss', views.my_sea_dismiss_free_draw_brief,
|
||||
name='my_sea_dismiss_free_draw_brief'),
|
||||
path('my-sea/brief/paid-draw/dismiss', views.my_sea_dismiss_paid_draw_brief,
|
||||
|
||||
@@ -865,6 +865,131 @@ def my_sea_invite_decline(request, invite_id):
|
||||
return _redirect_to_invite_log(invite)
|
||||
|
||||
|
||||
# ── Phase B — my-sea spectator (invitee) surfaces ───────────────────────────
|
||||
def _accepted_visit_invite(owner, user):
|
||||
"""The requester's ACCEPTED invite to spectate `owner`'s my-sea, or None.
|
||||
The single access gate for every spectator surface — a token deposit
|
||||
keeps the row ACCEPTED (presence derives from `token_deposited_at`); a
|
||||
BYE flips it to LEFT, which closes the gate."""
|
||||
from .models import SeaInvite
|
||||
return SeaInvite.objects.filter(
|
||||
owner=owner, invitee=user, status=SeaInvite.ACCEPTED,
|
||||
).order_by("-created_at").first()
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_sea_visit(request, owner_id):
|
||||
"""Spectator view — an ACCEPTED invitee watches the owner's my-sea read-
|
||||
only (Phase B of [[my-sea-invite-voice-blueprint]]). 403 unless an
|
||||
ACCEPTED SeaInvite(owner, request.user) exists; the owner is bounced to
|
||||
their own my_sea. Renders the table hex (seat 1C = owner-drawn, seat 2C =
|
||||
this visitor once present) + the owner's draw read-only via
|
||||
`latest_draw_slots`. No AUTO DRAW / DEL / FLIP-to-deposit on the owner's
|
||||
hand — `sea_btn_active` forced False."""
|
||||
from apps.lyric.models import User
|
||||
owner = get_object_or_404(User, id=owner_id)
|
||||
if owner == request.user:
|
||||
return redirect("my_sea")
|
||||
invite = _accepted_visit_invite(owner, request.user)
|
||||
if invite is None:
|
||||
return HttpResponseForbidden()
|
||||
owner_draw = active_draw_for(owner)
|
||||
sig_card, sig_reversed = _resolve_sig(owner, owner_draw)
|
||||
owner_hand_non_empty = owner_draw is not None and bool(owner_draw.hand)
|
||||
return render(request, "apps/gameboard/my_sea_visit.html", {
|
||||
"spectator": True,
|
||||
"is_owner": False,
|
||||
"read_only": True,
|
||||
"owner": owner,
|
||||
"sea_invite": invite,
|
||||
"seat1_present": owner_hand_non_empty,
|
||||
"seat2_present": invite.is_present,
|
||||
"owner_draw_id": owner_draw.id if owner_draw is not None else "",
|
||||
"voice_active": invite.voice_active,
|
||||
"significator": sig_card,
|
||||
"significator_reversed": sig_reversed,
|
||||
"my_sea_slots": latest_draw_slots(owner),
|
||||
"owner_hand_non_empty": owner_hand_non_empty,
|
||||
# Owner-only controls forced off on the spectator surface.
|
||||
"sea_btn_active": False,
|
||||
"sea_first_draw_pending": False,
|
||||
"page_class": "page-gameboard page-my-sea page-my-sea-visit",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_sea_visit_gate(request, owner_id):
|
||||
"""Visitor gate — single-step token deposit to occupy seat 2C + open the
|
||||
voice window. Reuses my_sea_gate.html with spectator=True (INSERT TOKEN
|
||||
only; no refund / PAID-DRAW two-step)."""
|
||||
from apps.lyric.models import User
|
||||
owner = get_object_or_404(User, id=owner_id)
|
||||
invite = _accepted_visit_invite(owner, request.user)
|
||||
if invite is None:
|
||||
return HttpResponseForbidden()
|
||||
sig_card, sig_reversed = _resolve_sig(owner, active_draw_for(owner))
|
||||
return render(request, "apps/gameboard/my_sea_gate.html", {
|
||||
"spectator": True,
|
||||
"owner": owner,
|
||||
"sea_invite": invite,
|
||||
"visit_owner_id": owner.id,
|
||||
"user_has_sig": sig_card is not None,
|
||||
"significator": sig_card,
|
||||
"significator_reversed": sig_reversed,
|
||||
# Visitor gate is single-step — no reserve/commit, so the refund +
|
||||
# PAID DRAW blocks (gated on `deposit_reserved`) never render.
|
||||
"deposit_reserved": False,
|
||||
"hand_non_empty": False,
|
||||
"page_class": "page-gameboard page-my-sea page-my-sea-gate page-my-sea-visit-gate",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_visit_insert_token(request, owner_id):
|
||||
"""Single-step visitor token deposit. Selects + debits the visitor's next-
|
||||
priority token (same priority chain as the owner gate), then records
|
||||
`token_deposited_at` + a 24h `voice_until` on the SeaInvite (NOT on the
|
||||
owner's MySeaDraw), marking seat 2C present + opening the voice window."""
|
||||
from datetime import timedelta
|
||||
from apps.lyric.models import User
|
||||
owner = get_object_or_404(User, id=owner_id)
|
||||
invite = _accepted_visit_invite(owner, request.user)
|
||||
if invite is None:
|
||||
return HttpResponseForbidden()
|
||||
if invite.token_deposited_at is None:
|
||||
token = _select_my_sea_token(request.user)
|
||||
if token is not None:
|
||||
debit_my_sea_token(request.user, token)
|
||||
now = timezone.now()
|
||||
invite.token_deposited_at = now
|
||||
invite.voice_until = now + timedelta(hours=24)
|
||||
invite.save(update_fields=["token_deposited_at", "voice_until"])
|
||||
return redirect("my_sea_visit", owner_id=owner.id)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_visit_leave(request, owner_id):
|
||||
"""Spectator BYE — drop presence: status=LEFT, left_at=now, voice killed
|
||||
(frees seat 2C + ends the voice window). Matches the requester's latest
|
||||
non-DECLINED invite for this owner."""
|
||||
from apps.lyric.models import User
|
||||
from .models import SeaInvite
|
||||
owner = get_object_or_404(User, id=owner_id)
|
||||
invite = (SeaInvite.objects
|
||||
.filter(owner=owner, invitee=request.user)
|
||||
.exclude(status=SeaInvite.DECLINED)
|
||||
.order_by("-created_at").first())
|
||||
if invite is None:
|
||||
return HttpResponseForbidden()
|
||||
invite.status = SeaInvite.LEFT
|
||||
invite.left_at = timezone.now()
|
||||
invite.voice_until = None
|
||||
invite.save(update_fields=["status", "left_at", "voice_until"])
|
||||
return redirect("gameboard")
|
||||
|
||||
|
||||
def _my_sea_deck_data(user, exclude_id=None):
|
||||
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
||||
picker's card-draw mechanic. Card payload shape is whatever
|
||||
|
||||
@@ -6,6 +6,7 @@ Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM
|
||||
(→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/
|
||||
mirrors the gate hint in its empty-state slot.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
@@ -1891,6 +1892,61 @@ class MySeaInviteAcceptanceLogTest(FunctionalTest):
|
||||
self.assertEqual(bye.text.upper(), "BYE")
|
||||
|
||||
|
||||
class MySeaSpectatorFlowTest(FunctionalTest):
|
||||
"""Phase B of [[my-sea-invite-voice-blueprint]] — an ACCEPTED invitee
|
||||
visits the owner's my-sea, deposits a token at the visitor gate, and
|
||||
thereby occupies seat 2C (the center btn flips GATE VIEW → VIEW DRAW)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.browser.set_window_size(800, 1200) # portrait — center-hex clicks
|
||||
_seed_gameboard_applets()
|
||||
from apps.gameboard.models import MySeaDraw, SeaInvite
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
# Owner has drawn → seat 1C is occupied + there's a draw to view.
|
||||
MySeaDraw.objects.create(
|
||||
user=self.owner, spread="situation-action-outcome",
|
||||
significator_id=1,
|
||||
hand=[{"position": "lay", "card_id": 1, "reversed": False,
|
||||
"polarity": "gravity"}],
|
||||
)
|
||||
self.email = "bud@test.io"
|
||||
self.bud = User.objects.create(email=self.email, username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.email,
|
||||
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||||
)
|
||||
|
||||
def test_spectator_deposits_token_to_occupy_seat_2c(self):
|
||||
from django.urls import reverse
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(
|
||||
self.live_server_url + reverse("my_sea_visit", args=[self.owner.id])
|
||||
)
|
||||
# Before deposit: GATE VIEW shown, seat 2C not yet seated.
|
||||
gate_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_my_sea_gate_view_btn")
|
||||
)
|
||||
seat2 = self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='2']"
|
||||
)
|
||||
self.assertNotIn("seated", seat2.get_attribute("class"))
|
||||
# GATE VIEW → visitor gate → INSERT TOKEN (single-step deposit).
|
||||
gate_btn.click()
|
||||
insert = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-rails")
|
||||
)
|
||||
insert.click()
|
||||
# Back on the visit page: VIEW DRAW now shown, seat 2C seated.
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_my_sea_view_draw_btn")
|
||||
)
|
||||
seat2 = self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='2']"
|
||||
)
|
||||
self.assertIn("seated", seat2.get_attribute("class"))
|
||||
|
||||
|
||||
class MySeaGearBtnTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6c — `.gear-btn` on every my-sea page state
|
||||
(landing / picker / gatekeeper). Opens a NVM-only menu (DEL/BYE
|
||||
|
||||
54
src/static/tests/MySeaSeatsSpec.js
Normal file
54
src/static/tests/MySeaSeatsSpec.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Jasmine spec for my-sea-seats.js — the one-shot "seated" glow module
|
||||
// (Phase B of the my-sea invite/voice sprint). Verifies the localStorage-
|
||||
// gated first-view behaviour + the timed flare-class removal.
|
||||
describe('my-sea-seats one-shot seated glow', function () {
|
||||
var seat;
|
||||
|
||||
beforeEach(function () {
|
||||
window.localStorage.clear();
|
||||
seat = document.createElement('div');
|
||||
seat.className = 'table-seat seated';
|
||||
document.body.appendChild(seat);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (seat && seat.parentNode) seat.parentNode.removeChild(seat);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('exposes playSeatGlow globally', function () {
|
||||
expect(typeof window.playSeatGlow).toBe('function');
|
||||
});
|
||||
|
||||
it('adds the seat-just-seated flare class', function () {
|
||||
window.playSeatGlow(seat);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a tokened seat seen in localStorage', function () {
|
||||
seat.setAttribute('data-seat-token', 'visit-42');
|
||||
window.playSeatGlow(seat);
|
||||
expect(window.localStorage.getItem('mysea-seat-seen:visit-42')).toBe('1');
|
||||
});
|
||||
|
||||
it('does not replay the flare for an already-seen token', function () {
|
||||
seat.setAttribute('data-seat-token', 'visit-42');
|
||||
window.localStorage.setItem('mysea-seat-seen:visit-42', '1');
|
||||
window.playSeatGlow(seat);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(false);
|
||||
});
|
||||
|
||||
it('always animates a tokenless seat (no persistence)', function () {
|
||||
window.playSeatGlow(seat);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes the flare class after the ~1.5s glow window', function () {
|
||||
jasmine.clock().install();
|
||||
window.playSeatGlow(seat);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(true);
|
||||
jasmine.clock().tick(1600);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(false);
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@
|
||||
<script src="RowLockSpec.js"></script>
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<script src="BurgerSpec.js"></script>
|
||||
<script src="MySeaSeatsSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
@@ -45,6 +46,7 @@
|
||||
<script src="/static/apps/epic/sea.js"></script>
|
||||
<script src="/static/apps/epic/burger-btn.js"></script>
|
||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||
<script src="/static/apps/gameboard/my-sea-seats.js"></script>
|
||||
<script src="/static/apps/gameboard/d3.min.js"></script>
|
||||
<script src="/static/apps/gameboard/sky-wheel.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
|
||||
@@ -551,6 +551,13 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
// my-sea seat one-shot "just seated" flare — see `.table-seat.seat-just-seated`.
|
||||
@keyframes my-sea-seat-flare {
|
||||
0% { color: rgba(var(--terUser), 1); filter: drop-shadow(0 0 6px rgba(var(--ninUser), 1)); }
|
||||
70% { color: rgba(var(--terUser), 1); filter: drop-shadow(0 0 6px rgba(var(--ninUser), 0.85)); }
|
||||
100% { color: rgba(var(--secUser), 1); filter: none; }
|
||||
}
|
||||
|
||||
.table-seat {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
@@ -616,6 +623,23 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
|
||||
filter: none;
|
||||
}
|
||||
|
||||
// ── my-sea "seated" occupancy (seat 1C owner, 2C visitor) ──────────────
|
||||
// Steady state once a seat is taken: chair settles to full-opacity
|
||||
// --secUser (mirrors .role-confirmed); the status icon is already
|
||||
// .fa-circle-check (green) from the server / JS swap.
|
||||
&.seated .fa-chair {
|
||||
color: rgba(var(--secUser), 1);
|
||||
filter: none;
|
||||
}
|
||||
// One-shot "just seated" flare (~1.5s) played the FIRST time a viewer
|
||||
// sees the occupancy (my-sea-seats.js adds/removes `.seat-just-seated`,
|
||||
// localStorage-gated). Chair flares --terUser + a --ninUser glow, then
|
||||
// eases back into the steady --secUser .seated look above (user-spec
|
||||
// 2026-05-27). Mirrors the room's .active → .role-confirmed handoff.
|
||||
&.seat-just-seated .fa-chair {
|
||||
animation: my-sea-seat-flare 1.5s ease forwards;
|
||||
}
|
||||
|
||||
.seat-portrait {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
54
src/static_src/tests/MySeaSeatsSpec.js
Normal file
54
src/static_src/tests/MySeaSeatsSpec.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Jasmine spec for my-sea-seats.js — the one-shot "seated" glow module
|
||||
// (Phase B of the my-sea invite/voice sprint). Verifies the localStorage-
|
||||
// gated first-view behaviour + the timed flare-class removal.
|
||||
describe('my-sea-seats one-shot seated glow', function () {
|
||||
var seat;
|
||||
|
||||
beforeEach(function () {
|
||||
window.localStorage.clear();
|
||||
seat = document.createElement('div');
|
||||
seat.className = 'table-seat seated';
|
||||
document.body.appendChild(seat);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (seat && seat.parentNode) seat.parentNode.removeChild(seat);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('exposes playSeatGlow globally', function () {
|
||||
expect(typeof window.playSeatGlow).toBe('function');
|
||||
});
|
||||
|
||||
it('adds the seat-just-seated flare class', function () {
|
||||
window.playSeatGlow(seat);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a tokened seat seen in localStorage', function () {
|
||||
seat.setAttribute('data-seat-token', 'visit-42');
|
||||
window.playSeatGlow(seat);
|
||||
expect(window.localStorage.getItem('mysea-seat-seen:visit-42')).toBe('1');
|
||||
});
|
||||
|
||||
it('does not replay the flare for an already-seen token', function () {
|
||||
seat.setAttribute('data-seat-token', 'visit-42');
|
||||
window.localStorage.setItem('mysea-seat-seen:visit-42', '1');
|
||||
window.playSeatGlow(seat);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(false);
|
||||
});
|
||||
|
||||
it('always animates a tokenless seat (no persistence)', function () {
|
||||
window.playSeatGlow(seat);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes the flare class after the ~1.5s glow window', function () {
|
||||
jasmine.clock().install();
|
||||
window.playSeatGlow(seat);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(true);
|
||||
jasmine.clock().tick(1600);
|
||||
expect(seat.classList.contains('seat-just-seated')).toBe(false);
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@
|
||||
<script src="RowLockSpec.js"></script>
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<script src="BurgerSpec.js"></script>
|
||||
<script src="MySeaSeatsSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
@@ -45,6 +46,7 @@
|
||||
<script src="/static/apps/epic/sea.js"></script>
|
||||
<script src="/static/apps/epic/burger-btn.js"></script>
|
||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||
<script src="/static/apps/gameboard/my-sea-seats.js"></script>
|
||||
<script src="/static/apps/gameboard/d3.min.js"></script>
|
||||
<script src="/static/apps/gameboard/sky-wheel.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
|
||||
@@ -11,5 +11,14 @@
|
||||
{% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %}
|
||||
<div id="id_my_sea_menu" style="display:none">
|
||||
<button type="button" class="btn btn-cancel" onclick="location.href='{{ nvm_url }}'">NVM</button>
|
||||
{# Spectator-only BYE (Phase B) — drops presence (status=LEFT, frees seat #}
|
||||
{# 2C, kills voice). Rendered below NVM only when the caller passes a #}
|
||||
{# `leave_url`; the owner's pages never do, so their menu is unchanged. #}
|
||||
{% if leave_url %}
|
||||
<form method="POST" action="{{ leave_url }}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" id="id_my_sea_bye_btn" class="btn btn-abandon">BYE</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "apps/applets/_partials/_gear.html" with menu_id="id_my_sea_menu" %}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{# Read-only render of the owner's draw for a my-sea spectator (Phase B of #}
|
||||
{# [[my-sea-invite-voice-blueprint]]). Mirrors `_applet-my-sea.html`'s slot #}
|
||||
{# markup off the same `latest_draw_slots` payload (`my_sea_slots`), but #}
|
||||
{# standalone + with NO interactive affordances (FLIP / DEL / AUTO DRAW). #}
|
||||
<div class="my-sea-scroll my-sea-visit-scroll">
|
||||
{% for slot in my_sea_slots %}
|
||||
{% if slot.card %}
|
||||
<div class="my-sea-slot-wrap">
|
||||
<div class="my-sea-slot my-sea-slot--filled my-sea-slot--{{ slot.polarity }}{% if slot.reversed %} my-sea-slot--reversed{% endif %}{% if slot.card.deck_variant.has_card_images %} my-sea-slot--image{% endif %}"
|
||||
data-position="{{ slot.position }}"
|
||||
data-card-id="{{ slot.card.id }}"
|
||||
data-arcana-key="{{ slot.card.arcana }}">
|
||||
{% if slot.card.deck_variant.has_card_images %}
|
||||
<img class="sig-stage-card-img" src="{{ slot.card.image_url }}" alt="{{ slot.card.name }}">
|
||||
{% else %}
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
{% if slot.face.qualifier_first %}
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
{% endif %}
|
||||
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="my-sea-slot-label">{{ slot.label }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="my-sea-slot-wrap">
|
||||
<div class="my-sea-slot my-sea-slot--empty" data-position="{{ slot.position }}"></div>
|
||||
<span class="my-sea-slot-label my-sea-slot-label--empty">{{ slot.label }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@
|
||||
{# semantics clean. `.position-status-icon` + #}
|
||||
{# `.fa-ban` are unchanged — already role- #}
|
||||
{# agnostic in _room.scss. #}
|
||||
<div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}">
|
||||
<div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}"{% if n == '1' and hand_non_empty %} data-seat-token="owner-{{ request.user.id }}-{{ active_draw.id }}"{% endif %}>
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">{{ n }}C</span>
|
||||
<i class="position-status-icon fa-solid {% if n == '1' and hand_non_empty %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
|
||||
@@ -964,6 +964,10 @@
|
||||
</script>
|
||||
|
||||
<script src="{% static 'apps/epic/room.js' %}"></script>
|
||||
{# Shared one-shot "seated" glow module — also drives seat 2C on #}
|
||||
{# the spectator page. Exposes window.playSeatGlow + auto-plays on #}
|
||||
{# load for any server-rendered .seated[data-seat-token] seat. #}
|
||||
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var page = document.querySelector('.my-sea-page');
|
||||
@@ -994,6 +998,11 @@
|
||||
statusIcon.classList.remove('fa-ban');
|
||||
statusIcon.classList.add('fa-circle-check');
|
||||
}
|
||||
// First-draw moment → play the one-shot seated flare
|
||||
// (same module the spectator page + load-time handler
|
||||
// use). On a later reload the server-rendered token
|
||||
// gates a repeat via localStorage.
|
||||
if (window.playSeatGlow) window.playSeatGlow(seat1);
|
||||
}
|
||||
setTimeout(function () {
|
||||
page.setAttribute('data-phase', 'picker');
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
{# their email prefix). #}
|
||||
<div class="gate-title-panel">
|
||||
<header class="gate-header">
|
||||
<h1>{{ user|at_handle }}'s Sea</h1>
|
||||
{# Spectator gate titles the OWNER's Sea (the visitor is #}
|
||||
{# depositing into someone else's table); owner gate #}
|
||||
{# titles their own. #}
|
||||
<h1>{% if spectator %}{{ owner|at_handle }}{% else %}{{ user|at_handle }}{% endif %}'s Sea</h1>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +43,7 @@
|
||||
<div class="gate-main-panel">
|
||||
<div class="token-slot{% if not deposit_reserved %} active{% else %} pending{% endif %}">
|
||||
{% if not deposit_reserved %}
|
||||
<form method="POST" action="{% url 'my_sea_insert_token' %}" style="display:contents">
|
||||
<form method="POST" action="{% if spectator %}{% url 'my_sea_visit_insert_token' visit_owner_id %}{% else %}{% url 'my_sea_insert_token' %}{% endif %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="token-rails" aria-label="Insert token to play">
|
||||
<span class="rail"></span>
|
||||
@@ -87,12 +90,19 @@
|
||||
{# + gear-btn NVM-only menu. Both render outside .gate-modal so the #}
|
||||
{# bud-btn lives at viewport fixed position (per `_bud.scss`) and #}
|
||||
{# the gear-btn sits atop `#id_kit_btn` in the bottom-right corner. #}
|
||||
{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}
|
||||
{# Gatekeeper NVM nav-backs to the my-sea TABLE HEX (landing) so the #}
|
||||
{# user lands one step back from the gatekeeper rather than ejecting #}
|
||||
{# all the way to /gameboard/. #}
|
||||
{% url 'my_sea' as nvm_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
|
||||
{# Bud invite is owner-only — a spectator doesn't invite others into #}
|
||||
{# someone else's Sea. #}
|
||||
{% if not spectator %}{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}{% endif %}
|
||||
{# NVM nav-backs to the my-sea TABLE HEX one step back. For a spectator #}
|
||||
{# that's the owner's visit landing + a BYE drops presence entirely. #}
|
||||
{% if spectator %}
|
||||
{% url 'my_sea_visit' visit_owner_id as nvm_url %}
|
||||
{% url 'my_sea_visit_leave' visit_owner_id as leave_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" with nvm_url=nvm_url leave_url=leave_url %}
|
||||
{% else %}
|
||||
{% url 'my_sea' as nvm_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
|
||||
{% endif %}
|
||||
{% include "apps/gameboard/_partials/_burger.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
106
src/templates/apps/gameboard/my_sea_visit.html
Normal file
106
src/templates/apps/gameboard/my_sea_visit.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Game Sea{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
{# Phase B spectator surface — an ACCEPTED invitee watches {{ owner|at_handle }}'s #}
|
||||
{# Sea read-only. The owner's my_sea.html is left untouched; this is a #}
|
||||
{# dedicated, simpler template: the table hex (seat 1C = owner, 2C = this #}
|
||||
{# visitor) + the owner's draw rendered read-only (no FLIP / DEL / AUTO #}
|
||||
{# DRAW). GATE VIEW opens the visitor gate; once a token is deposited the #}
|
||||
{# center btn becomes VIEW DRAW (toggles the read-only draw into view). #}
|
||||
<div class="my-sea-page my-sea-visit-page"
|
||||
data-phase="landing"
|
||||
data-spectator="true"
|
||||
data-polarity="{% if significator_reversed %}levity{% else %}gravity{% endif %}">
|
||||
|
||||
<div class="my-sea-landing my-sea-visit-landing">
|
||||
<div class="room-shell">
|
||||
<div id="id_game_table" class="room-table">
|
||||
<div class="room-table-scene">
|
||||
<div class="table-hex-border">
|
||||
<div class="table-hex">
|
||||
<div class="table-center">
|
||||
{% if seat2_present %}
|
||||
{# Visitor is present — VIEW DRAW reveals the owner's #}
|
||||
{# read-only draw (client-side toggle below). #}
|
||||
<button id="id_my_sea_view_draw_btn" type="button"
|
||||
class="btn btn-primary">VIEW<br>DRAW</button>
|
||||
{% else %}
|
||||
{# Not yet present — GATE VIEW → visitor token gate. #}
|
||||
<button id="id_my_sea_gate_view_btn" type="button"
|
||||
class="btn btn-primary"
|
||||
data-gate-url="{% url 'my_sea_visit_gate' owner.id %}">GATE<br>VIEW</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% for n in "123456" %}
|
||||
{% if n == '1' and seat1_present %}
|
||||
<div class="table-seat seated" data-slot="1"
|
||||
data-seat-token="owner-{{ owner.id }}-{{ owner_draw_id }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">1C</span>
|
||||
<i class="position-status-icon fa-solid fa-circle-check"></i>
|
||||
</div>
|
||||
{% elif n == '2' and seat2_present %}
|
||||
<div class="table-seat seated" data-slot="2"
|
||||
data-seat-token="visit-{{ sea_invite.id }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">2C</span>
|
||||
<i class="position-status-icon fa-solid fa-circle-check"></i>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-seat" data-slot="{{ n }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">{{ n }}C</span>
|
||||
<i class="position-status-icon fa-solid fa-ban"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if seat2_present %}
|
||||
{# Owner's draw, read-only. Hidden until VIEW DRAW toggles it in. #}
|
||||
<div id="id_my_sea_visit_draw" class="my-sea-visit-draw" style="display:none">
|
||||
{% include "apps/gameboard/_partials/_my_sea_readonly_draw.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Gear menu — NVM (back to /gameboard/) + BYE (drop presence, free 2C). #}
|
||||
{% url 'my_sea_visit_leave' owner.id as leave_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" with leave_url=leave_url %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
// VIEW DRAW toggles the read-only draw against the table hex.
|
||||
var viewBtn = document.getElementById('id_my_sea_view_draw_btn');
|
||||
var draw = document.getElementById('id_my_sea_visit_draw');
|
||||
var landing = document.querySelector('.my-sea-visit-landing');
|
||||
if (viewBtn && draw) {
|
||||
viewBtn.addEventListener('click', function () {
|
||||
var showing = draw.style.display !== 'none';
|
||||
draw.style.display = showing ? 'none' : '';
|
||||
if (landing) landing.style.display = showing ? '' : 'none';
|
||||
});
|
||||
}
|
||||
// GATE VIEW → visitor gate.
|
||||
var gateBtn = document.getElementById('id_my_sea_gate_view_btn');
|
||||
if (gateBtn) {
|
||||
gateBtn.addEventListener('click', function () {
|
||||
window.location.href = gateBtn.dataset.gateUrl || '#';
|
||||
});
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
{% endblock scripts %}
|
||||
Reference in New Issue
Block a user