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,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>