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_USER_POST = "user_post"
KIND_SHARE_INVITE = "share_invite"
KIND_GAME_INVITE = "game_invite"
KIND_CHOICES = [
(KIND_NOTE_UNLOCK, "Note unlock"),
(KIND_USER_POST, "User post"),
(KIND_SHARE_INVITE, "Share invite"),
(KIND_GAME_INVITE, "Game invite"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -106,10 +108,25 @@ class Brief(models.Model):
related_name="briefs",
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,
related_name="briefs",
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
# 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
carries a square_url pointing at /billboard/my-notes/ so the
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 = ""
if self.kind == self.KIND_NOTE_UNLOCK:
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 {
"id": str(self.id),
"kind": self.kind,
"title": self.title,
"line_text": self.line.text if self.line else "",
"post_url": self.post.get_absolute_url(),
"post_url": post_url,
"square_url": square_url,
"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)
self.assertIsNone(b.line)
def test_brief_owner_post_required(self):
"""Brief without owner OR post is invalid; both are the load-bearing
FKs (owner = whose attention; post = where FYI navigates)."""
def test_brief_owner_required(self):
"""Brief without owner is invalid (load-bearing for "whose
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
with transaction.atomic(), self.assertRaises(IntegrityError):
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):
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
def invite_gamer(request, room_id):
if request.method == "POST":
"""Gatekeeper invite flow. Backwards-compatible w. the legacy
`invitee_email` form-submit (still POSTs from any old caller); also
serves the new bud-btn slide-out which sends `recipient` (email OR
username) + Accept: application/json. Bud-btn flow:
• Resolves recipient via _resolve_recipient (registered → User; else None).
• Stores RoomInvite using the resolved email (or raw input if unregistered).
• Auto-adds inviter ↔ recipient to each others' buds (symmetric, per
share_post precedent — registered recipients only).
• 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)
email = request.POST.get("invitee_email", "").strip()
if email:
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=email,
defaults={"status": RoomInvite.PENDING}
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)

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(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
# 2. Founder invites friend
invite_input = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_invite_email")
# 2. Founder invites friend via the bud-btn slide-out (replaces
# the legacy inline #id_invite_email form post-bud-btn refactor).
bud_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_bud_btn")
)
invite_input.send_keys("friend@test.io")
self.browser.find_element(By.ID, "id_invite_btn").click()
bud_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
self.create_pre_authenticated_session("friend@test.io")
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>
{% if request.user == room.owner %}
<div class="gate-invite-panel">
<h3>Invite Friend</h3>
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}" style="display:flex; gap:0.5rem; align-items:center;">
{% csrf_token %}
<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 %}
{# Legacy gate-invite-panel retired in favour of #id_bud_btn at #}
{# the upper-right of the footer (room.html includes the bud #}
{# invite panel partial when the viewer owns the room). #}
</div>
</div>

View File

@@ -92,6 +92,12 @@
{% endif %}
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
{% 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 %}
{% 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 %}>
@@ -117,6 +123,9 @@
{% endblock content %}
{% 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/gatekeeper.js' %}"></script>
<script src="{% static 'apps/epic/role-select.js' %}"></script>