gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD

- Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set.
  - epic.invite_gamer view refactor:
    • Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat).
    • Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username).
    • RoomInvite stores the resolved User's email (or raw input if unregistered).
    • Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User.
    • Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null.
    • Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before.
    • Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch.
  - _gatekeeper.html: gate-invite-panel block (lines 62-71) removed.
  - new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE.
  - room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner.
  - Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add).
  - test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable).
  - test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references).
  - 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper FTs green.

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:
Disco DeDisco
2026-05-09 00:59:54 -04:00
parent 4010e452a6
commit 419e022140
10 changed files with 549 additions and 32 deletions

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-05-09 04:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0006_alter_line_options'),
('epic', '0008_blades_reversal_fickle'),
]
operations = [
migrations.AddField(
model_name='brief',
name='room',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='epic.room'),
),
migrations.AlterField(
model_name='brief',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite')], default='user_post', max_length=32),
),
migrations.AlterField(
model_name='brief',
name='post',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post'),
),
]

View File

@@ -94,10 +94,12 @@ class Brief(models.Model):
KIND_NOTE_UNLOCK = "note_unlock" KIND_NOTE_UNLOCK = "note_unlock"
KIND_USER_POST = "user_post" KIND_USER_POST = "user_post"
KIND_SHARE_INVITE = "share_invite" KIND_SHARE_INVITE = "share_invite"
KIND_GAME_INVITE = "game_invite"
KIND_CHOICES = [ KIND_CHOICES = [
(KIND_NOTE_UNLOCK, "Note unlock"), (KIND_NOTE_UNLOCK, "Note unlock"),
(KIND_USER_POST, "User post"), (KIND_USER_POST, "User post"),
(KIND_SHARE_INVITE, "Share invite"), (KIND_SHARE_INVITE, "Share invite"),
(KIND_GAME_INVITE, "Game invite"),
] ]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -106,10 +108,25 @@ class Brief(models.Model):
related_name="briefs", related_name="briefs",
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
# Post is nullable now: KIND_GAME_INVITE briefs ride on a Room FK
# instead of a Post (the gatekeeper invite confirmation has no post
# to navigate to). Post FKs only set for note_unlock / user_post /
# share_invite kinds.
post = models.ForeignKey( post = models.ForeignKey(
Post, Post,
related_name="briefs", related_name="briefs",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
)
# Room FK — set only on KIND_GAME_INVITE briefs; FYI navigates to
# the gatekeeper page for that room.
room = models.ForeignKey(
"epic.Room",
related_name="briefs",
on_delete=models.CASCADE,
null=True,
blank=True,
) )
# Line is nullable because a share_invite-style Brief can race ahead of its # Line is nullable because a share_invite-style Brief can race ahead of its
# async-appended Line write; the post FK alone is enough to navigate. # async-appended Line write; the post FK alone is enough to navigate.
@@ -142,16 +159,23 @@ class Brief(models.Model):
"""Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind """Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind
carries a square_url pointing at /billboard/my-notes/ so the carries a square_url pointing at /billboard/my-notes/ so the
thumbnail-square inside the banner jumps direct to the user's Note thumbnail-square inside the banner jumps direct to the user's Note
collection — other kinds get an empty square_url.""" collection. GAME_INVITE kind has no Post — the FYI link navigates
to the gatekeeper page for the brief's Room instead."""
square_url = "" square_url = ""
if self.kind == self.KIND_NOTE_UNLOCK: if self.kind == self.KIND_NOTE_UNLOCK:
square_url = reverse("billboard:my_notes") square_url = reverse("billboard:my_notes")
if self.post_id:
post_url = self.post.get_absolute_url()
elif self.room_id:
post_url = reverse("epic:gatekeeper", args=[self.room_id])
else:
post_url = ""
return { return {
"id": str(self.id), "id": str(self.id),
"kind": self.kind, "kind": self.kind,
"title": self.title, "title": self.title,
"line_text": self.line.text if self.line else "", "line_text": self.line.text if self.line else "",
"post_url": self.post.get_absolute_url(), "post_url": post_url,
"square_url": square_url, "square_url": square_url,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
} }

View File

@@ -40,14 +40,14 @@ class BriefModelTest(TestCase):
b = Brief.objects.create(owner=self.user, post=self.post) b = Brief.objects.create(owner=self.user, post=self.post)
self.assertIsNone(b.line) self.assertIsNone(b.line)
def test_brief_owner_post_required(self): def test_brief_owner_required(self):
"""Brief without owner OR post is invalid; both are the load-bearing """Brief without owner is invalid (load-bearing for "whose
FKs (owner = whose attention; post = where FYI navigates).""" attention"). Post used to be required too, but became nullable
when GAME_INVITE briefs landed (those use Brief.room instead of
Brief.post). The view layer enforces "post XOR room" per kind."""
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
with transaction.atomic(), self.assertRaises(IntegrityError): with transaction.atomic(), self.assertRaises(IntegrityError):
Brief.objects.create(post=self.post, line=self.line) Brief.objects.create(post=self.post, line=self.line)
with transaction.atomic(), self.assertRaises(IntegrityError):
Brief.objects.create(owner=self.user, line=self.line)
def test_brief_carries_title(self): def test_brief_carries_title(self):
b = Brief.objects.create( b = Brief.objects.create(

View File

@@ -0,0 +1,143 @@
"""ITs for the gatekeeper invite_gamer view post-bud-btn refactor.
The legacy form-submit path (POST `invitee_email` + 302 redirect) still
works for any pre-existing caller; the new bud-btn slide-out POSTs
`recipient` (email OR username) w. Accept: application/json and gets
back {brief, recipient_display}. On registered recipients we auto-add
both directions of the buds graph (mirrors share_post per the Phase 2
spec) and spawn a Brief w. kind=GAME_INVITE pointing at the room.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Brief
from apps.epic.models import Room, RoomInvite
from apps.lyric.models import User
class InviteGamerAjaxTest(TestCase):
"""Bud-btn flow: POST /room/<id>/gate/invite w. Accept: application/json."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.client.force_login(self.owner)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
def _invite(self, recipient):
return self.client.post(
reverse("epic:invite_gamer", args=[self.room.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_ajax_invite_returns_brief_payload(self):
response = self._invite("alice@test.io")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertIn("brief", body)
self.assertIn("recipient_display", body)
self.assertIsNotNone(body["brief"])
def test_ajax_invite_creates_room_invite(self):
self._invite("alice@test.io")
self.assertTrue(RoomInvite.objects.filter(
room=self.room, inviter=self.owner, invitee_email="alice@test.io",
).exists())
def test_ajax_invite_resolves_username_to_email(self):
"""Username-typed recipient stores the resolved User's email."""
self._invite("alice")
self.assertTrue(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io",
).exists())
def test_ajax_invite_creates_brief_with_game_invite_kind_and_room_fk(self):
self._invite("alice@test.io")
brief = Brief.objects.get(owner=self.owner)
self.assertEqual(brief.kind, Brief.KIND_GAME_INVITE)
self.assertEqual(brief.room, self.room)
self.assertIsNone(brief.post)
self.assertTrue(brief.is_unread)
def test_ajax_invite_brief_banner_dict_links_to_room_gatekeeper(self):
body = self._invite("alice@test.io").json()
self.assertEqual(
body["brief"]["post_url"],
reverse("epic:gatekeeper", args=[self.room.id]),
)
def test_ajax_invite_auto_adds_recipient_to_inviter_buds(self):
self._invite("alice@test.io")
self.assertIn(self.alice, self.owner.buds.all())
def test_ajax_invite_auto_adds_inviter_to_recipient_buds_symmetric(self):
"""Per Phase 2 spec: shared events imply mutual buds graph link."""
self._invite("alice@test.io")
self.assertIn(self.owner, self.alice.buds.all())
def test_ajax_invite_unregistered_email_creates_invite_no_buds_add(self):
"""Privacy + correctness: unregistered recipient still gets a
RoomInvite (so they can accept after registration), but the
inviter's buds list isn't touched (we don't auto-add a non-User)."""
response = self._invite("ghost@test.io")
self.assertEqual(response.status_code, 200)
self.assertTrue(RoomInvite.objects.filter(
room=self.room, invitee_email="ghost@test.io",
).exists())
self.assertEqual(self.owner.buds.count(), 0)
def test_ajax_invite_self_is_silent_noop(self):
"""Inviting yourself: brief=null, no RoomInvite, no buds touch."""
response = self._invite("owner@test.io")
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["brief"])
self.assertFalse(RoomInvite.objects.filter(room=self.room).exists())
def test_ajax_invite_blank_recipient_is_silent_noop(self):
response = self._invite("")
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["brief"])
self.assertFalse(RoomInvite.objects.filter(room=self.room).exists())
def test_ajax_invite_recipient_display_is_username_when_registered(self):
body = self._invite("alice@test.io").json()
self.assertEqual(body["recipient_display"], "alice")
def test_ajax_invite_recipient_display_is_null_when_unregistered(self):
body = self._invite("ghost@test.io").json()
self.assertIsNone(body["recipient_display"])
class InviteGamerLegacyFormTest(TestCase):
"""Form-submit path (no Accept: application/json) preserved — older
callers still get a 302 redirect to the gatekeeper. The legacy
`invitee_email` field name is also still accepted for full backwards
compat, even though the new bud-btn uses `recipient`."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")
self.client.force_login(self.owner)
self.room = Room.objects.create(name="Test", owner=self.owner)
def test_form_submit_with_invitee_email_redirects(self):
response = self.client.post(
reverse("epic:invite_gamer", args=[self.room.id]),
data={"invitee_email": "alice@test.io"},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response["Location"],
reverse("epic:gatekeeper", args=[self.room.id]),
)
def test_form_submit_with_recipient_field_also_works(self):
"""The bud-btn field name (recipient) also works on form submit."""
response = self.client.post(
reverse("epic:invite_gamer", args=[self.room.id]),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 302)
self.assertTrue(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io",
).exists())

View File

@@ -656,16 +656,81 @@ def pick_roles(request, room_id):
@login_required @login_required
def invite_gamer(request, room_id): def invite_gamer(request, room_id):
if request.method == "POST": """Gatekeeper invite flow. Backwards-compatible w. the legacy
room = Room.objects.get(id=room_id) `invitee_email` form-submit (still POSTs from any old caller); also
email = request.POST.get("invitee_email", "").strip() serves the new bud-btn slide-out which sends `recipient` (email OR
if email: username) + Accept: application/json. Bud-btn flow:
RoomInvite.objects.get_or_create( • Resolves recipient via _resolve_recipient (registered → User; else None).
room=room, • Stores RoomInvite using the resolved email (or raw input if unregistered).
inviter=request.user, • Auto-adds inviter ↔ recipient to each others' buds (symmetric, per
invitee_email=email, share_post precedent — registered recipients only).
defaults={"status": RoomInvite.PENDING} • Spawns a Brief w. kind=GAME_INVITE + room=room (post=null).
) • Returns JSON {brief, recipient_display} when Accept matches; else
redirects to gatekeeper as before."""
if request.method != "POST":
return redirect("epic:gatekeeper", room_id=room_id)
from apps.billboard.models import Brief
from apps.billboard.views import _resolve_recipient
room = Room.objects.get(id=room_id)
is_ajax = "application/json" in request.headers.get("Accept", "")
# New bud-btn field name is `recipient`; legacy form uses `invitee_email`.
raw = (
request.POST.get("recipient")
or request.POST.get("invitee_email")
or ""
).strip()
if not raw:
if is_ajax:
return JsonResponse({"brief": None, "recipient_display": None})
return redirect("epic:gatekeeper", room_id=room_id)
candidate = _resolve_recipient(raw)
is_self = candidate is not None and candidate == request.user
if is_self:
if is_ajax:
return JsonResponse({"brief": None, "recipient_display": None})
return redirect("epic:gatekeeper", room_id=room_id)
# RoomInvite uses the resolved User's email when available (so a
# username-typed invite doesn't store the raw username as if it were
# an email); falls back to the raw input for unregistered addresses.
invitee_email = candidate.email if candidate else raw
RoomInvite.objects.get_or_create(
room=room,
inviter=request.user,
invitee_email=invitee_email,
defaults={"status": RoomInvite.PENDING},
)
# Buds graph: symmetric auto-add on registered recipients (mirrors
# share_post). Idempotent on M2M; no-op on unregistered recipients.
if candidate is not None:
request.user.buds.add(candidate)
candidate.buds.add(request.user)
# Brief: confirmation banner for the inviter. Brief.post stays null;
# banner FYI navigates to the room's gatekeeper page via Brief.room.
brief = Brief.objects.create(
owner=request.user,
post=None,
room=room,
kind=Brief.KIND_GAME_INVITE,
title="Invite sent",
)
if is_ajax:
recipient_display = None
if candidate is not None:
recipient_display = candidate.username or candidate.email
return JsonResponse({
"brief": brief.to_banner_dict(),
"recipient_display": recipient_display,
})
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -0,0 +1,116 @@
"""FT for the gatekeeper invite via #id_bud_btn slide-out.
Replaces the legacy inline `<form action="invite_gamer">` panel inside
the gatekeeper modal. The bud-btn lives at the upper-right corner of
the right sidebar (footer in landscape); slide-out hosts the email/
username field + OK btn. Submit fires async POST to
epic:invite_gamer w. Accept: application/json — server returns
{brief, recipient_display}, JS shows the slide-down Brief banner.
"""
from selenium.webdriver.common.by import By
from apps.billboard.models import Brief
from apps.epic.models import Room, RoomInvite
from apps.lyric.models import User
from .base import FunctionalTest
class GatekeeperBudBtnPresenceTest(FunctionalTest):
"""The bud-btn renders for the room owner during gate phase, and is
absent for non-owners (friend invites are owner-only)."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.gamer = User.objects.create(email="gamer@test.io", username="gamer")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
def test_bud_btn_renders_for_owner(self):
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
def test_bud_btn_absent_for_non_owner(self):
# A registered non-owner viewer doesn't see the invite affordance.
self.create_pre_authenticated_session("gamer@test.io")
self.browser.get(self.room_url)
# Gatekeeper-specific element confirms page rendered
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
def test_legacy_invite_email_input_is_gone(self):
"""Sanity: the old inline form has been removed."""
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
self.assertFalse(self.browser.find_elements(By.ID, "id_invite_email"))
class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
"""OK on the bud-btn slide-out fires the async invite — RoomInvite
persisted, Brief w/ kind=GAME_INVITE created, slide-down banner shown."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
def _open_panel_and_invite(self, recipient):
bud_btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
bud_btn.click()
recipient_input = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_recipient")
)
recipient_input.send_keys(recipient)
self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
).click()
return bud_btn
def test_invite_creates_room_invite(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertEqual(
RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io"
).count(),
1,
))
def test_invite_spawns_game_invite_brief(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertEqual(
Brief.objects.filter(
owner=self.owner, kind=Brief.KIND_GAME_INVITE,
).count(),
1,
))
def test_invite_renders_slide_down_banner(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))
def test_invite_closes_panel_after_success(self):
bud_btn = self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertNotIn("active", bud_btn.get_attribute("class")))
def test_invite_username_resolves_to_user_email(self):
"""Username-typed invite stores the resolved User's email."""
self._open_panel_and_invite("alice")
self.wait_for(lambda: self.assertEqual(
RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io"
).count(),
1,
))
def test_invite_auto_adds_recipient_to_owner_buds(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertIn(
self.alice, list(self.owner.buds.all())
))

View File

@@ -127,12 +127,19 @@ class GatekeeperTest(FunctionalTest):
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
) )
# 2. Founder invites friend # 2. Founder invites friend via the bud-btn slide-out (replaces
invite_input = self.wait_for( # the legacy inline #id_invite_email form post-bud-btn refactor).
lambda: self.browser.find_element(By.ID, "id_invite_email") bud_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_bud_btn")
) )
invite_input.send_keys("friend@test.io") bud_btn.click()
self.browser.find_element(By.ID, "id_invite_btn").click() recipient = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_recipient")
)
recipient.send_keys("friend@test.io")
self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
).click()
# 3. Friend logs in, sees invitation in My Games # 3. Friend logs in, sees invitation in My Games
self.create_pre_authenticated_session("friend@test.io") self.create_pre_authenticated_session("friend@test.io")
self.browser.get(self.live_server_url + "/gameboard/") self.browser.get(self.live_server_url + "/gameboard/")

View File

@@ -0,0 +1,130 @@
{% load static %}
{% load lyric_extras %}
{# ─────────────────────────────────────────────────────────────────────── #}
{# _bud_invite_panel.html — bud btn + slide-out for the gatekeeper game- #}
{# invite flow. Replaces the legacy `<form action="invite_gamer">` #}
{# inline panel inside _gatekeeper.html. #}
{# #}
{# Differences from the post-share variant (_bud_panel.html): #}
{# • POSTs to epic:invite_gamer instead of billboard:share_post. #}
{# • Server returns {brief, recipient_display} — no line_text (no Post #}
{# to append a Line to). JS just shows the Brief banner. #}
{# #}
{# Caller must pass `room` in context. #}
{# ─────────────────────────────────────────────────────────────────────── #}
<button id="id_bud_btn" type="button" aria-label="Invite a friend">
<i class="fa-solid fa-handshake"></i>
</button>
<div id="id_bud_panel"
data-invite-url="{% url 'epic:invite_gamer' room.id %}"
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
<input id="id_recipient"
name="recipient"
type="text"
placeholder="friend@example.com or username"
autocomplete="off">
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
</div>
{# Autocomplete suggestions — sibling because the panel has overflow:hidden #}
{# for the slide-in scaleX animation. Pulls from request.user.buds. #}
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
<script>
bindBudAutocomplete(
document.getElementById('id_recipient'),
document.getElementById('id_bud_suggestions'),
{ searchUrl: '{% url "billboard:search_buds" %}' }
);
</script>
<script>
(function () {
'use strict';
var btn = document.getElementById('id_bud_btn');
var panel = document.getElementById('id_bud_panel');
var input = document.getElementById('id_recipient');
var ok = document.getElementById('id_bud_ok');
var html = document.documentElement;
if (!btn || !panel || !input || !ok) return;
function _csrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function _open() {
html.classList.add('bud-open');
btn.classList.add('active');
setTimeout(function () { input.focus(); }, 60);
}
function _close(opts) {
opts = opts || {};
html.classList.remove('bud-open');
btn.classList.remove('active');
if (opts.clear !== false) input.value = '';
}
btn.addEventListener('click', function () {
if (html.classList.contains('bud-open')) {
_close();
} else {
_open();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
});
document.addEventListener('click', function (e) {
if (!html.classList.contains('bud-open')) return;
if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
// Suggestions live outside the panel; clicking inside them must
// NOT close+clear the panel.
var sg = document.getElementById('id_bud_suggestions');
if (sg && sg.contains(e.target)) return;
_close();
});
ok.addEventListener('click', function () {
var recipient = input.value.trim();
if (!recipient) return;
var fd = new FormData();
fd.set('recipient', recipient);
fetch(panel.dataset.inviteUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-CSRFToken': _csrf(),
},
body: fd,
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
if (window.Brief && data.brief) Brief.showBanner(data.brief);
_close({ clear: true });
})
.catch(function () {
// Privacy-safe: even unregistered/self resolves to 200
// {brief: null}; only network/5xx land here. Just close.
});
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
ok.click();
}
});
}());
</script>

View File

@@ -59,16 +59,9 @@
</div> </div>
</div> </div>
{% if request.user == room.owner %} {# Legacy gate-invite-panel retired in favour of #id_bud_btn at #}
<div class="gate-invite-panel"> {# the upper-right of the footer (room.html includes the bud #}
<h3>Invite Friend</h3> {# invite panel partial when the viewer owns the room). #}
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}" style="display:flex; gap:0.5rem; align-items:center;">
{% csrf_token %}
<input type="email" name="invitee_email" id="id_invite_email" class="form-control form-control-lg" placeholder="friend@example.com" style="flex:1; min-width:0;" hx-preserve>
<button type="submit" id="id_invite_btn" class="btn btn-confirm">OK</button>
</form>
</div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -92,6 +92,12 @@
{% endif %} {% endif %}
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %} {% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
{% include "apps/gameboard/_partials/_gatekeeper.html" %} {% include "apps/gameboard/_partials/_gatekeeper.html" %}
{# Owner-only invite affordance: handshake btn at the upper-right #}
{# of the right sidebar w. slide-out + autocomplete. Replaces the #}
{# legacy inline `<form action="invite_gamer">` panel. #}
{% if request.user == room.owner %}
{% include "apps/billboard/_partials/_bud_invite_panel.html" %}
{% endif %}
{% endif %} {% endif %}
{% if room.table_status %} {% if room.table_status %}
<div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" and starter_roles|length < 6 %} class="role-select-phase"{% endif %}> <div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" and starter_roles|length < 6 %} class="role-select-phase"{% endif %}>
@@ -117,6 +123,9 @@
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
{# Brief module — needed by _bud_invite_panel's OK handler so the #}
{# slide-down banner shows up on a successful gatekeeper invite. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<script src="{% static 'apps/epic/room.js' %}"></script> <script src="{% static 'apps/epic/room.js' %}"></script>
<script src="{% static 'apps/epic/gatekeeper.js' %}"></script> <script src="{% static 'apps/epic/gatekeeper.js' %}"></script>
<script src="{% static 'apps/epic/role-select.js' %}"></script> <script src="{% static 'apps/epic/role-select.js' %}"></script>