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:
130
src/templates/apps/billboard/_partials/_bud_invite_panel.html
Normal file
130
src/templates/apps/billboard/_partials/_bud_invite_panel.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user