bud page sea-btn cascade: live-invite window + accept-on-GET + glow handoff; my-buds tooltip clamp + row hover/lock — TDD
Fixes the Bill Bud invite cascade so the sea sub-btn actually lights + leads to the bud's my_sea, and gives the My Buds row tooltip viewport clamping + hover/lock styling. SeaInvite.invitee_access_open (gameboard/models.py): new invitee-facing access window — a non-terminal invite (PENDING/ACCEPTED) within 24h of being proffered OR within 24h of the invitee's last gate token deposit. Re-arms on each deposit; DECLINED/LEFT/EXPIRED stay shut. Distinct from is_expired (which only models the PENDING lapse). 8 UTs. bud_page (billboard/views.py): sea_btn_active / sea_first_draw_pending now key on invitee_access_open across PENDING + ACCEPTED, not PENDING-only. Old design darkened the btn the instant the user accepted, so they could never reach the bud's sea from here post-accept — that was the red .fa-ban the user saw. ITs updated: accepted-within-window now lights; added stale-accepted-dark + recent-deposit-relights cases. my_sea_visit (gameboard/views.py): accept-on-GET — a still-pending, non-expired invite from the owner to the visitor is accepted implicitly on arrival (the sea-btn cascade + @mailman post-attribution anchor both land here, so the click IS the acceptance). Previously PENDING → 403, so the cascade dead-ended. ITs: pending-invitee now auto-accepts (200); expired-pending still 403s; stranger still 403s. bud.html: burger → sea_btn glow-handoff machine (the my_sea.html cascade minus the spread-modal stage) so the glow rides the affordance chain to the click target; active sea click clears glow, preserves .active, navigates. my-buds-tooltip.js: clamp the position:fixed #id_tooltip_portal to the viewport on row-lock — same 1rem-inset shape as game-kit.js / sky-wheel.js / wallet.js (measure after .active, clamp left, prefer above / flip below). Reset on clear. _billboard.scss: .bud-entry hover + .row-locked highlight (rows aren't .row-3col so the existing rule missed them) — fill --secUser, flip the --terUser handle to --quiUser, trailing title to readable --priUser. 520 billboard+gameboard ITs/UTs green; affected sea-btn-cascade FT green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,40 @@
|
|||||||
_lockedRow.classList.remove('row-locked');
|
_lockedRow.classList.remove('row-locked');
|
||||||
_lockedRow = null;
|
_lockedRow = null;
|
||||||
}
|
}
|
||||||
if (_portal) _portal.classList.remove('active');
|
if (_portal) {
|
||||||
|
_portal.classList.remove('active');
|
||||||
|
// Reset positional props so the next show measures fresh.
|
||||||
|
_portal.style.top = '';
|
||||||
|
_portal.style.bottom = '';
|
||||||
|
_portal.style.left = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp the position:fixed portal to the viewport — same 1rem-inset
|
||||||
|
// shape as game-kit.js / sky-wheel.js / wallet.js. Called AFTER .active
|
||||||
|
// makes the portal display:block so offsetWidth/Height are real: clamp
|
||||||
|
// the left edge into [rem, viewport-ttW-rem], then prefer ABOVE the row
|
||||||
|
// (flip BELOW when the tooltip is too tall to fit above).
|
||||||
|
function _positionPortal(row) {
|
||||||
|
if (!_portal) return;
|
||||||
|
var rect = row.getBoundingClientRect();
|
||||||
|
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
|
var ttW = _portal.offsetWidth;
|
||||||
|
var ttH = _portal.offsetHeight;
|
||||||
|
|
||||||
|
var minLeft = rem;
|
||||||
|
var maxLeft = window.innerWidth - ttW - rem;
|
||||||
|
var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft));
|
||||||
|
_portal.style.left = clampedLeft + 'px';
|
||||||
|
|
||||||
|
var spaceAbove = rect.top - rem;
|
||||||
|
if (ttH <= spaceAbove) {
|
||||||
|
_portal.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||||
|
_portal.style.top = '';
|
||||||
|
} else {
|
||||||
|
_portal.style.top = (rect.bottom + 8) + 'px';
|
||||||
|
_portal.style.bottom = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _findSlot(name) {
|
function _findSlot(name) {
|
||||||
@@ -75,7 +108,10 @@
|
|||||||
row.classList.add('row-locked');
|
row.classList.add('row-locked');
|
||||||
_lockedRow = row;
|
_lockedRow = row;
|
||||||
_populatePortal(row);
|
_populatePortal(row);
|
||||||
if (_portal) _portal.classList.add('active');
|
if (_portal) {
|
||||||
|
_portal.classList.add('active');
|
||||||
|
_positionPortal(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1251,10 +1251,13 @@ class BudPageAutoAddOnFirstVisitTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class BudPagePendingInviteCascadeTest(TestCase):
|
class BudPagePendingInviteCascadeTest(TestCase):
|
||||||
"""`sea_btn_active` + `sea_first_draw_pending` fire iff a non-expired
|
"""`sea_btn_active` + `sea_first_draw_pending` fire iff a *live* SeaInvite
|
||||||
PENDING SeaInvite exists from this bud (owner) to the viewer (invitee).
|
exists from this bud (owner) to the viewer (invitee) — non-terminal
|
||||||
Reuses the same template flags `_burger.html` already reads on my_sea
|
(PENDING or ACCEPTED) AND inside its 24h-from-proffer window OR within 24h
|
||||||
+ room — no new template plumbing on bud.html."""
|
of the viewer's last gate token deposit (user-spec 2026-05-29, via
|
||||||
|
`SeaInvite.invitee_access_open`). Reuses the same template flags
|
||||||
|
`_burger.html` already reads on my_sea + room — no new template plumbing
|
||||||
|
on bud.html."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
from apps.gameboard.models import SeaInvite
|
from apps.gameboard.models import SeaInvite
|
||||||
@@ -1286,7 +1289,10 @@ class BudPagePendingInviteCascadeTest(TestCase):
|
|||||||
self.assertTrue(response.context["sea_btn_active"])
|
self.assertTrue(response.context["sea_btn_active"])
|
||||||
self.assertTrue(response.context["sea_first_draw_pending"])
|
self.assertTrue(response.context["sea_first_draw_pending"])
|
||||||
|
|
||||||
def test_accepted_invite_does_not_cascade(self):
|
def test_accepted_invite_within_window_lights_cascade(self):
|
||||||
|
# New spec: an ACCEPTED invite still inside its 24h window keeps the
|
||||||
|
# cascade lit (the old design went dark the instant it accepted, so
|
||||||
|
# the user could never reach the bud's sea from here post-accept).
|
||||||
self.SeaInvite.objects.create(
|
self.SeaInvite.objects.create(
|
||||||
owner=self.alice,
|
owner=self.alice,
|
||||||
invitee=self.user,
|
invitee=self.user,
|
||||||
@@ -1296,8 +1302,9 @@ class BudPagePendingInviteCascadeTest(TestCase):
|
|||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("billboard:bud_page", args=[self.alice.id])
|
reverse("billboard:bud_page", args=[self.alice.id])
|
||||||
)
|
)
|
||||||
self.assertIsNone(response.context["pending_invite"])
|
self.assertIsNotNone(response.context["pending_invite"])
|
||||||
self.assertFalse(response.context["sea_btn_active"])
|
self.assertTrue(response.context["sea_btn_active"])
|
||||||
|
self.assertTrue(response.context["sea_first_draw_pending"])
|
||||||
|
|
||||||
def test_expired_pending_invite_does_not_cascade(self):
|
def test_expired_pending_invite_does_not_cascade(self):
|
||||||
inv = self.SeaInvite.objects.create(
|
inv = self.SeaInvite.objects.create(
|
||||||
@@ -1315,6 +1322,41 @@ class BudPagePendingInviteCascadeTest(TestCase):
|
|||||||
self.assertIsNone(response.context["pending_invite"])
|
self.assertIsNone(response.context["pending_invite"])
|
||||||
self.assertFalse(response.context["sea_btn_active"])
|
self.assertFalse(response.context["sea_btn_active"])
|
||||||
|
|
||||||
|
def test_stale_accepted_without_deposit_does_not_cascade(self):
|
||||||
|
inv = self.SeaInvite.objects.create(
|
||||||
|
owner=self.alice,
|
||||||
|
invitee=self.user,
|
||||||
|
invitee_email=self.user.email,
|
||||||
|
status=self.SeaInvite.ACCEPTED,
|
||||||
|
)
|
||||||
|
self.SeaInvite.objects.filter(pk=inv.pk).update(
|
||||||
|
created_at=timezone.now() - timezone.timedelta(hours=48),
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("billboard:bud_page", args=[self.alice.id])
|
||||||
|
)
|
||||||
|
self.assertIsNone(response.context["pending_invite"])
|
||||||
|
self.assertFalse(response.context["sea_btn_active"])
|
||||||
|
|
||||||
|
def test_recent_deposit_relights_cascade_past_invite_window(self):
|
||||||
|
# Invite proffered 3 days ago but a gate token deposit 5h ago re-arms
|
||||||
|
# the 24h window — the "OR 24h since last token deposit" clause.
|
||||||
|
inv = self.SeaInvite.objects.create(
|
||||||
|
owner=self.alice,
|
||||||
|
invitee=self.user,
|
||||||
|
invitee_email=self.user.email,
|
||||||
|
status=self.SeaInvite.ACCEPTED,
|
||||||
|
token_deposited_at=timezone.now() - timezone.timedelta(hours=5),
|
||||||
|
)
|
||||||
|
self.SeaInvite.objects.filter(pk=inv.pk).update(
|
||||||
|
created_at=timezone.now() - timezone.timedelta(hours=72),
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("billboard:bud_page", args=[self.alice.id])
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(response.context["pending_invite"])
|
||||||
|
self.assertTrue(response.context["sea_btn_active"])
|
||||||
|
|
||||||
def test_invite_for_other_invitee_ignored(self):
|
def test_invite_for_other_invitee_ignored(self):
|
||||||
# Pending invite from alice → some other user is irrelevant to ME.
|
# Pending invite from alice → some other user is irrelevant to ME.
|
||||||
other = User.objects.create(email="other@inv.io", username="other")
|
other = User.objects.create(email="other@inv.io", username="other")
|
||||||
|
|||||||
@@ -603,29 +603,37 @@ def bud_page(request, bud_id):
|
|||||||
Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses
|
Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses
|
||||||
the same template variables `_burger.html` already reads on my_sea +
|
the same template variables `_burger.html` already reads on my_sea +
|
||||||
room — server-side conditional renders `glow-handoff` on the burger
|
room — server-side conditional renders `glow-handoff` on the burger
|
||||||
+ `.active` on the sea sub-btn. The flags fire iff a non-expired
|
+ `.active` on the sea sub-btn. The flags fire iff a *live* SeaInvite
|
||||||
PENDING SeaInvite exists from this bud to the viewer."""
|
exists from this bud to the viewer — non-terminal (PENDING or ACCEPTED)
|
||||||
|
AND inside its 24h-from-proffer window OR within 24h of the viewer's
|
||||||
|
last gate token deposit (user-spec 2026-05-29, `invitee_access_open`).
|
||||||
|
Accepting the invite no longer darkens the btn; the cascade now stays
|
||||||
|
lit across the whole window so the user can reach the bud's sea
|
||||||
|
(`my_sea_visit` accepts a still-pending invite on GET)."""
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from apps.gameboard.models import SeaInvite
|
from apps.gameboard.models import SeaInvite
|
||||||
bud = get_object_or_404(User, id=bud_id)
|
bud = get_object_or_404(User, id=bud_id)
|
||||||
if bud != request.user and not request.user.buds.filter(id=bud.id).exists():
|
if bud != request.user and not request.user.buds.filter(id=bud.id).exists():
|
||||||
request.user.buds.add(bud)
|
request.user.buds.add(bud)
|
||||||
bn = BudshipNote.objects.filter(user=request.user, bud=bud).first()
|
bn = BudshipNote.objects.filter(user=request.user, bud=bud).first()
|
||||||
pending = (
|
live = (
|
||||||
SeaInvite.objects
|
SeaInvite.objects
|
||||||
.filter(owner=bud, invitee=request.user, status=SeaInvite.PENDING)
|
.filter(
|
||||||
|
owner=bud, invitee=request.user,
|
||||||
|
status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED],
|
||||||
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if pending is not None and pending.is_expired:
|
if live is not None and not live.invitee_access_open:
|
||||||
pending = None
|
live = None
|
||||||
return render(request, "apps/billboard/bud.html", {
|
return render(request, "apps/billboard/bud.html", {
|
||||||
"bud": bud,
|
"bud": bud,
|
||||||
"shoptalk_text": bn.shoptalk if bn else "",
|
"shoptalk_text": bn.shoptalk if bn else "",
|
||||||
"milestone_dt": bn.edited_at if bn else None,
|
"milestone_dt": bn.edited_at if bn else None,
|
||||||
"pending_invite": pending,
|
"pending_invite": live,
|
||||||
"sea_btn_active": pending is not None,
|
"sea_btn_active": live is not None,
|
||||||
"sea_first_draw_pending": pending is not None,
|
"sea_first_draw_pending": live is not None,
|
||||||
"page_class": "page-billbud",
|
"page_class": "page-billbud",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -419,3 +419,26 @@ class SeaInvite(models.Model):
|
|||||||
and self.token_deposited_at is not None
|
and self.token_deposited_at is not None
|
||||||
and self.left_at is None
|
and self.left_at is None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def invitee_access_open(self):
|
||||||
|
"""True iff the invitee's bud-page sea sub-btn should be lit AND the
|
||||||
|
owner's spectator my-sea is reachable — a non-terminal invite within
|
||||||
|
24h of being proffered OR within 24h of the invitee's last token
|
||||||
|
deposit at the owner's visitor gate (user-spec 2026-05-29).
|
||||||
|
|
||||||
|
The deposit clause re-arms the window: as long as the invitee keeps
|
||||||
|
depositing at the table they keep the door open, independent of the
|
||||||
|
original 24h invite window. DECLINED / LEFT / EXPIRED are terminal —
|
||||||
|
the door stays shut. Distinct from `is_expired` (which only models the
|
||||||
|
PENDING lapse): this is the invitee-facing access window and spans
|
||||||
|
both PENDING + ACCEPTED."""
|
||||||
|
if self.status not in (self.PENDING, self.ACCEPTED):
|
||||||
|
return False
|
||||||
|
now = timezone.now()
|
||||||
|
if now < self.expires_at:
|
||||||
|
return True
|
||||||
|
if self.token_deposited_at is not None:
|
||||||
|
window = timezone.timedelta(hours=SEA_INVITE_EXPIRE_HOURS)
|
||||||
|
return now < self.token_deposited_at + window
|
||||||
|
return False
|
||||||
|
|||||||
@@ -43,10 +43,29 @@ class MySeaVisitGuardTest(TestCase):
|
|||||||
self.client.force_login(stranger)
|
self.client.force_login(stranger)
|
||||||
self.assertEqual(self.client.get(self.url).status_code, 403)
|
self.assertEqual(self.client.get(self.url).status_code, 403)
|
||||||
|
|
||||||
def test_pending_invitee_is_forbidden(self):
|
def test_pending_invitee_is_auto_accepted_on_visit(self):
|
||||||
|
# Accept-on-GET (user-spec 2026-05-29): following the bud-page sea-btn
|
||||||
|
# cascade — or the @mailman post-attribution anchor — to the spectator
|
||||||
|
# table implicitly accepts a still-pending invite, so the page renders
|
||||||
|
# instead of 403ing the legitimately-invited user.
|
||||||
self.invite.status = SeaInvite.PENDING
|
self.invite.status = SeaInvite.PENDING
|
||||||
|
self.invite.accepted_at = None
|
||||||
self.invite.save()
|
self.invite.save()
|
||||||
self.client.force_login(self.bud)
|
self.client.force_login(self.bud)
|
||||||
|
self.assertEqual(self.client.get(self.url).status_code, 200)
|
||||||
|
self.invite.refresh_from_db()
|
||||||
|
self.assertEqual(self.invite.status, SeaInvite.ACCEPTED)
|
||||||
|
self.assertIsNotNone(self.invite.accepted_at)
|
||||||
|
|
||||||
|
def test_expired_pending_invitee_is_forbidden(self):
|
||||||
|
# A lapsed PENDING invite (no deposit, past 24h) is NOT auto-accepted.
|
||||||
|
self.invite.status = SeaInvite.PENDING
|
||||||
|
self.invite.accepted_at = None
|
||||||
|
self.invite.save()
|
||||||
|
SeaInvite.objects.filter(pk=self.invite.pk).update(
|
||||||
|
created_at=timezone.now() - timedelta(hours=48),
|
||||||
|
)
|
||||||
|
self.client.force_login(self.bud)
|
||||||
self.assertEqual(self.client.get(self.url).status_code, 403)
|
self.assertEqual(self.client.get(self.url).status_code, 403)
|
||||||
|
|
||||||
def test_left_invitee_is_forbidden(self):
|
def test_left_invitee_is_forbidden(self):
|
||||||
|
|||||||
@@ -97,3 +97,68 @@ class SeaInviteStatusMachineTest(SimpleTestCase):
|
|||||||
left_at=timezone.now(),
|
left_at=timezone.now(),
|
||||||
)
|
)
|
||||||
self.assertFalse(inv.is_present)
|
self.assertFalse(inv.is_present)
|
||||||
|
|
||||||
|
# ── invitee_access_open ──────────────────────────────────────────────
|
||||||
|
# The invitee-facing access window: lights the bud-page sea sub-btn AND
|
||||||
|
# keeps the owner's spectator my-sea reachable. Open for a non-terminal
|
||||||
|
# invite within 24h of being proffered OR within 24h of the invitee's
|
||||||
|
# last gate token deposit (user-spec 2026-05-29).
|
||||||
|
def test_access_open_for_fresh_pending(self):
|
||||||
|
inv = self._invite(created_at=timezone.now() - timedelta(hours=1))
|
||||||
|
self.assertTrue(inv.invitee_access_open)
|
||||||
|
|
||||||
|
def test_access_open_for_accepted_within_invite_window(self):
|
||||||
|
# The key fix: an ACCEPTED invite still inside its 24h window keeps
|
||||||
|
# the sea btn lit (the old design went dark the moment it accepted).
|
||||||
|
inv = self._invite(
|
||||||
|
status=SeaInvite.ACCEPTED,
|
||||||
|
created_at=timezone.now() - timedelta(hours=5),
|
||||||
|
)
|
||||||
|
self.assertTrue(inv.invitee_access_open)
|
||||||
|
|
||||||
|
def test_access_closed_for_stale_pending_without_deposit(self):
|
||||||
|
inv = self._invite(created_at=timezone.now() - timedelta(hours=25))
|
||||||
|
self.assertFalse(inv.invitee_access_open)
|
||||||
|
|
||||||
|
def test_access_closed_for_stale_accepted_without_deposit(self):
|
||||||
|
inv = self._invite(
|
||||||
|
status=SeaInvite.ACCEPTED,
|
||||||
|
created_at=timezone.now() - timedelta(hours=25),
|
||||||
|
)
|
||||||
|
self.assertFalse(inv.invitee_access_open)
|
||||||
|
|
||||||
|
def test_access_open_within_24h_of_last_deposit_past_invite_window(self):
|
||||||
|
# Invite proffered 3 days ago, but the invitee deposited a token at
|
||||||
|
# the owner's gate 5h ago — the deposit clause re-arms the window.
|
||||||
|
inv = self._invite(
|
||||||
|
status=SeaInvite.ACCEPTED,
|
||||||
|
created_at=timezone.now() - timedelta(hours=72),
|
||||||
|
token_deposited_at=timezone.now() - timedelta(hours=5),
|
||||||
|
)
|
||||||
|
self.assertTrue(inv.invitee_access_open)
|
||||||
|
|
||||||
|
def test_access_closed_when_both_windows_lapsed(self):
|
||||||
|
inv = self._invite(
|
||||||
|
status=SeaInvite.ACCEPTED,
|
||||||
|
created_at=timezone.now() - timedelta(hours=72),
|
||||||
|
token_deposited_at=timezone.now() - timedelta(hours=25),
|
||||||
|
)
|
||||||
|
self.assertFalse(inv.invitee_access_open)
|
||||||
|
|
||||||
|
def test_access_closed_for_declined(self):
|
||||||
|
inv = self._invite(
|
||||||
|
status=SeaInvite.DECLINED,
|
||||||
|
created_at=timezone.now() - timedelta(hours=1),
|
||||||
|
)
|
||||||
|
self.assertFalse(inv.invitee_access_open)
|
||||||
|
|
||||||
|
def test_access_closed_for_left_even_with_recent_deposit(self):
|
||||||
|
# A BYE is a deliberate exit — the door stays shut even though the
|
||||||
|
# token deposit is recent.
|
||||||
|
inv = self._invite(
|
||||||
|
status=SeaInvite.LEFT,
|
||||||
|
created_at=timezone.now() - timedelta(hours=1),
|
||||||
|
token_deposited_at=timezone.now() - timedelta(hours=1),
|
||||||
|
left_at=timezone.now(),
|
||||||
|
)
|
||||||
|
self.assertFalse(inv.invitee_access_open)
|
||||||
|
|||||||
@@ -896,11 +896,26 @@ def my_sea_visit(request, owner_id):
|
|||||||
their own my_sea. Renders the table hex (seat 1C = owner-drawn, seat 2C =
|
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
|
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
|
`latest_draw_slots`. No AUTO DRAW / DEL / FLIP-to-deposit on the owner's
|
||||||
hand — `sea_btn_active` forced False."""
|
hand — `sea_btn_active` forced False.
|
||||||
|
|
||||||
|
Accept-on-GET (user-spec 2026-05-29): a still-pending, non-expired invite
|
||||||
|
from `owner` to this user is accepted implicitly on arrival — the bud-page
|
||||||
|
sea-btn cascade (+ the @mailman post-attribution anchor) both land here,
|
||||||
|
and the click IS the acceptance. A stranger with no invite still 403s."""
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
from .models import SeaInvite
|
||||||
owner = get_object_or_404(User, id=owner_id)
|
owner = get_object_or_404(User, id=owner_id)
|
||||||
if owner == request.user:
|
if owner == request.user:
|
||||||
return redirect("my_sea")
|
return redirect("my_sea")
|
||||||
|
pending = (
|
||||||
|
SeaInvite.objects
|
||||||
|
.filter(owner=owner, invitee=request.user, status=SeaInvite.PENDING)
|
||||||
|
.order_by("-created_at").first()
|
||||||
|
)
|
||||||
|
if pending is not None and not pending.is_expired:
|
||||||
|
pending.status = SeaInvite.ACCEPTED
|
||||||
|
pending.accepted_at = timezone.now()
|
||||||
|
pending.save(update_fields=["status", "accepted_at"])
|
||||||
invite = _accepted_visit_invite(owner, request.user)
|
invite = _accepted_visit_invite(owner, request.user)
|
||||||
if invite is None:
|
if invite is None:
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
|
|||||||
@@ -110,6 +110,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// My Buds rows are `.bud-entry` (`@<handle> the <Title>`), NOT `.row-3col`,
|
||||||
|
// so the row-3col hover/lock highlight above doesn't reach them. Mirror it
|
||||||
|
// here: hover (mouse) + `.row-locked` (click-lock set by my-buds-tooltip.js)
|
||||||
|
// fill the row --secUser and flip the --terUser handle to --quiUser, with the
|
||||||
|
// trailing title brought to the readable --priUser back-of-card colour. The
|
||||||
|
// (0,3,1) handle rule out-specifies the generic `.applet-list-entry a:hover`
|
||||||
|
// glow above, so the lit row reads --quiUser even under the cursor.
|
||||||
|
.applet-list-entry.bud-entry {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: background-color 0.12s ease, color 0.12s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.row-locked {
|
||||||
|
background-color: rgba(var(--secUser), 1);
|
||||||
|
|
||||||
|
.bud-name a {
|
||||||
|
color: rgba(var(--quiUser), 1);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.bud-row-title { color: rgba(var(--priUser), 1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.applet-list-buffer {
|
.applet-list-buffer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
|
|||||||
@@ -69,14 +69,26 @@
|
|||||||
{% if pending_invite %}
|
{% if pending_invite %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
var burger = document.getElementById('id_burger_btn');
|
||||||
var sea = document.getElementById('id_sea_btn');
|
var sea = document.getElementById('id_sea_btn');
|
||||||
if (!sea) return;
|
if (!burger || !sea) return;
|
||||||
// Active sub-btn navigation handler — fires BEFORE burger-btn.js's
|
// Glow-handoff machine — the bud-page counterpart of my_sea.html's
|
||||||
// delegated fan handler (target-phase before bubble-up) so the click
|
// burger → sea_btn cascade, minus the spread-modal stage (the active
|
||||||
// routes to the bud's spectator my-sea page. Burger then closes the
|
// sea sub-btn navigates away instead of opening a modal). The burger
|
||||||
// fan as usual.
|
// carries .glow-handoff server-side (sea_first_draw_pending); opening
|
||||||
|
// it hands the glow off to the sea sub-btn so the nudge rides the
|
||||||
|
// affordance chain to the click target.
|
||||||
|
burger.addEventListener('click', function () {
|
||||||
|
if (!burger.classList.contains('glow-handoff')) return;
|
||||||
|
burger.classList.remove('glow-handoff');
|
||||||
|
sea.classList.add('glow-handoff');
|
||||||
|
});
|
||||||
|
// Active sea sub-btn click → the bud's spectator my-sea (which accepts
|
||||||
|
// the invite on GET). Clears the glow but PRESERVES .active. Fires in
|
||||||
|
// the target phase, BEFORE burger-btn.js's delegated fan-close.
|
||||||
sea.addEventListener('click', function () {
|
sea.addEventListener('click', function () {
|
||||||
if (!sea.classList.contains('active')) return;
|
if (!sea.classList.contains('active')) return;
|
||||||
|
sea.classList.remove('glow-handoff');
|
||||||
window.location.href = '/gameboard/my-sea/visit/{{ bud.id }}/';
|
window.location.href = '/gameboard/my-sea/visit/{{ bud.id }}/';
|
||||||
});
|
});
|
||||||
}());
|
}());
|
||||||
|
|||||||
Reference in New Issue
Block a user