Files
guestguard/frontend/pages/dashboard/events/[id].vue
T
Kwaku Danso 39533162bb feat(tier2): editable RSVPs — Block A
Guests can revisit their invitation link and change their response
or plus-ones up to 5 times. Each prior state is snapshotted into
`rsvp_revisions` and surfaced to the host via a per-guest history
modal on the event detail page.

- Migration 0007 adds rsvp_revisions + rsvps.edit_count (with down)
- RSVPRepo.Update wraps snapshot+update+counter in one transaction,
  FOR UPDATE-locking the row so concurrent edits can't bypass the cap
- PATCH /rsvp/{token} re-runs the fraud check on every edit attempt
  (different device on an edit is itself a signal)
- POST /rsvp no longer marks the token used — the link stays valid
  so the guest can come back to edit
- GET /access/{token} now embeds the existing RSVP so the frontend
  renders an edit form instead of a blank submit form on revisit
- New host endpoint GET /events/{id}/guests/{guest_id}/rsvp/history
- Frontend: rsvp/[token].vue toggles between summary + edit form,
  surfaces edits-remaining; dashboard adds a "History" action on
  responded guests opening a revision-trail modal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:27:50 +01:00

1587 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
definePageMeta({ middleware: ['auth'] })
interface Guest {
id: string
event_id: string
name: string
email?: string | null
phone?: string | null
plus_ones: number
created_at: string
rsvp_response?: 'attending' | 'declined' | 'maybe' | null
rsvp_plus_ones?: number | null
rsvp_risk_score?: number | null
rsvp_submitted_at?: string | null
has_token?: boolean
}
interface GuestStats {
total: number
attending: number
declined: number
maybe: number
pending: number
}
interface EventDetail {
id: string
host_id: string
name: string
slug: string
event_date: string
venue: string
max_capacity: number
status: string
created_at: string
updated_at: string
}
interface IssuedToken {
token: string
token_id?: string
invitation_queued: boolean
invitation_link?: string
}
interface WSMessage {
type: string
event_id: string
payload: any
timestamp: string
}
const route = useRoute()
const eventId = route.params.id as string
const event = ref<EventDetail | null>(null)
const guests = ref<Guest[]>([])
const stats = ref<GuestStats>({ total: 0, attending: 0, declined: 0, maybe: 0, pending: 0 })
const filter = ref<'all' | 'attending' | 'declined' | 'maybe' | 'pending'>('all')
const loading = ref(true)
async function refresh() {
const [evt, list] = await Promise.all([
useApi<EventDetail>(`/events/${eventId}`),
useApi<{ guests: Guest[]; stats: GuestStats }>(`/events/${eventId}/guests`),
])
event.value = evt
guests.value = list.guests || []
if (list.stats) stats.value = list.stats
}
const filteredGuests = computed(() => {
if (filter.value === 'all') return guests.value
if (filter.value === 'pending') return guests.value.filter((g) => !g.rsvp_response)
return guests.value.filter((g) => g.rsvp_response === filter.value)
})
// Grouping for the "All" view. UX-wise we order by what the host needs to
// act on next: invites still to send, then in-flight, then ambivalent
// replies, then confirmed, then declined. Empty groups don't render.
// Other filters render flat (the filter chip is itself the label).
interface GuestGroup {
key: string
label: string
guests: Guest[]
}
const groupedGuests = computed<GuestGroup[]>(() => {
if (filter.value !== 'all') {
return [{ key: 'flat', label: '', guests: filteredGuests.value }]
}
const needsInvite: Guest[] = []
const invited: Guest[] = []
const maybe: Guest[] = []
const attending: Guest[] = []
const declined: Guest[] = []
for (const g of guests.value) {
if (g.rsvp_response === 'attending') attending.push(g)
else if (g.rsvp_response === 'maybe') maybe.push(g)
else if (g.rsvp_response === 'declined') declined.push(g)
else if (g.has_token) invited.push(g)
else needsInvite.push(g)
}
return [
{ key: 'needs', label: 'Needs invitation', guests: needsInvite },
{ key: 'invited', label: 'Invited · awaiting reply', guests: invited },
{ key: 'maybe', label: 'Maybe', guests: maybe },
{ key: 'attending', label: 'Attending', guests: attending },
{ key: 'declined', label: 'Declined', guests: declined },
].filter((grp) => grp.guests.length > 0)
})
interface ActivityItem {
type: 'rsvp' | 'access_check'
ts: string
guest_id: string
guest_name: string
// RSVP
response?: string
plus_ones?: number
// Access check
score?: number
band?: string
blocked?: boolean
}
onMounted(async () => {
try {
await refresh()
// Pull history from the activity endpoint. The WebSocket hub only
// broadcasts future events, so without this catch-up the monitor
// is blank until a guest does something while the dashboard is open.
backfillFeed()
} finally {
loading.value = false
}
})
async function backfillFeed() {
if (feed.value.length > 0) return // don't clobber WS items that arrived first
try {
const res = await useApi<{ activity: ActivityItem[] }>(
`/events/${eventId}/activity?limit=50`,
)
feed.value = (res.activity || []).map(activityToFeedItem)
} catch (e) {
console.error('activity backfill failed', e)
}
}
function activityToFeedItem(a: ActivityItem): FeedItem {
if (a.type === 'rsvp') {
return {
type: 'rsvp.confirmed',
ts: a.ts,
text: `${a.guest_name}${a.response} (+${a.plus_ones || 0})`,
}
}
// access_check — friendly text per band, matching the live-WS handler.
const band = a.band || 'low'
const who = a.guest_name || 'A guest'
const friendlyText: Record<string, string> = {
low: `${who} opened their invitation — looks normal.`,
medium: `${who}'s access looks a bit unusual — worth a glance.`,
high: `${who}'s access looks suspicious — review when you can.`,
block: `${who}'s access was blocked.`,
}
return {
type: 'fraud.scored',
ts: a.ts,
text: friendlyText[band] || `${who}'s invitation was checked.`,
band,
}
}
// Add-guest form — opens via toolbar button. Reset on open so previous
// values don't bleed across sessions.
const newGuest = reactive({ name: '', email: '', phone: '', plus_ones: 0 })
const addingGuest = ref(false)
const addingGuestOpen = ref(false)
const importingOpen = ref(false)
function resetNewGuest() {
newGuest.name = ''
newGuest.email = ''
newGuest.phone = ''
newGuest.plus_ones = 0
}
function openAddGuest() {
resetNewGuest()
addingGuestOpen.value = true
}
function handleImported() {
importingOpen.value = false
refresh()
showToast({ kind: 'success', text: 'Guests imported' })
}
// --- WhatsApp send wizard (Option A+) ---
//
// No official API — uses wa.me click-to-chat. Per-guest the host taps
// "Send", we mint a fresh token (so any earlier email link for the same
// guest stops working, by design), build a pre-filled wa.me URL, and
// open it in a new tab. The host hits Send inside WhatsApp.
//
// We persist *which guests have been wizard-sent* (booleans only, no
// tokens) so closing/reopening the modal preserves the host's progress
// — important when the list is 30+ guests.
const whatsappOpen = ref(false)
const waSending = ref<string | null>(null)
const waStorageKey = `gg.wa-sent.${eventId}`
const waSent = ref<Set<string>>(loadWaSent())
function loadWaSent(): Set<string> {
if (!import.meta.client) return new Set()
try {
const raw = window.localStorage.getItem(waStorageKey)
return raw ? new Set(JSON.parse(raw)) : new Set()
} catch {
return new Set()
}
}
function persistWaSent() {
if (!import.meta.client) return
try {
window.localStorage.setItem(waStorageKey, JSON.stringify(Array.from(waSent.value)))
} catch { /* quota / private mode */ }
}
watch(waSent, persistWaSent, { deep: true })
const waEligible = computed(() => guests.value.filter((g) => !!g.phone && g.phone.trim() !== ''))
const waNoPhoneCount = computed(() => guests.value.length - waEligible.value.length)
const waSentCount = computed(() => {
// Intersect with current eligible set — drops stale ids from deleted guests.
let n = 0
for (const g of waEligible.value) if (waSent.value.has(g.id)) n++
return n
})
function whatsappUrl(phone: string, text: string): string {
// wa.me wants digits only — no +, spaces, dashes, or parens.
const digits = phone.replace(/\D/g, '')
return `https://wa.me/${digits}?text=${encodeURIComponent(text)}`
}
// E.164-ish: a usable wa.me number must include the country code. The two
// strongest signals are (a) explicit leading "+", or (b) ≥10 digits with
// no leading 0 after symbols are stripped. UK 07700... and similar local
// formats will look "almost right" but break in WhatsApp, so we'd rather
// flag them now than have the host see the cryptic "couldn't be opened"
// error inside the app.
function isLikelyE164(phone: string | null | undefined): boolean {
if (!phone) return false
const trimmed = phone.trim()
if (trimmed.startsWith('+')) {
return /^\+\d{8,15}$/.test(trimmed.replace(/[\s\-()]/g, ''))
}
const digits = trimmed.replace(/\D/g, '')
// No leading +. Only safe if it doesn't start with 0 and is long enough
// to plausibly include a country code (≥10 digits).
return digits.length >= 10 && !digits.startsWith('0')
}
function whatsappMessage(name: string, link: string): string {
const ev = event.value?.name || 'an event'
return `Hi ${name}, you're invited to ${ev}! Please RSVP here: ${link}`
}
async function sendOneViaWhatsapp(g: Guest) {
if (!g.phone) return
waSending.value = g.id
try {
// Rotate (no email) gives us a fresh raw token + canonical link.
const res = await useApi<IssuedToken>(
`/events/${eventId}/guests/${g.id}/tokens/rotate`,
{ method: 'POST', body: { send_email: false } },
)
const link = res.invitation_link || rsvpUrl(res.token)
const url = whatsappUrl(g.phone, whatsappMessage(g.name, link))
if (import.meta.client) {
const win = window.open(url, '_blank', 'noopener')
if (!win) {
showToast({ kind: 'error', text: 'Browser blocked the WhatsApp window — allow pop-ups for this site.' })
return
}
}
// Optimistic mark — the host might still abandon inside WhatsApp,
// but the marker reflects "I started this one", which is the value
// a host scanning the list needs to track their own progress.
waSent.value = new Set([...waSent.value, g.id])
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not start WhatsApp send') })
} finally {
waSending.value = null
}
}
function resetWaSent() {
waSent.value = new Set()
}
// Opens the existing edit-guest modal on top of the wizard. After save,
// refresh() rehydrates `guests` and the wizard's eligibility computeds
// recompute, so a fixed phone becomes sendable without further action.
function fixPhoneFromWizard(g: Guest) {
openEdit(g)
}
async function addGuest() {
addingGuest.value = true
try {
await useApi(`/events/${eventId}/guests`, {
method: 'POST',
body: {
name: newGuest.name,
email: newGuest.email || null,
phone: newGuest.phone || null,
plus_ones: Number(newGuest.plus_ones) || 0,
},
})
const justAdded = newGuest.name.trim()
resetNewGuest()
addingGuestOpen.value = false
await refresh()
showToast({ kind: 'success', text: `Added ${justAdded}` })
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not add guest') })
} finally {
addingGuest.value = false
}
}
// In-memory only — raw invitation tokens are never persisted client-side.
// After a session refresh the host re-acquires links via the Regenerate
// modal, which mints a fresh token (invalidating the prior one).
const issued = ref<Record<string, IssuedToken>>({})
const issuing = ref<string | null>(null)
const copiedFor = ref<string | null>(null)
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
// Bulk invite — selection model
interface BulkInviteToken {
guest_id: string
token: string
invitation_queued: boolean
invitation_link: string
}
interface BulkInviteResult {
issued: number
queued: number
skipped_existing: number
skipped_no_email: number
tokens?: BulkInviteToken[]
errors?: { guest_id: string; reason: string }[]
}
const bulkConfirmOpen = ref(false)
const bulkSending = ref(false)
const selectedIds = ref<Set<string>>(new Set())
// --- Row actions: edit / delete (Needs invitation), regenerate (Invited) ---
const editing = ref<Guest | null>(null)
const editForm = reactive({ name: '', email: '', phone: '', plus_ones: 0 })
const savingEdit = ref(false)
function openEdit(g: Guest) {
editing.value = g
editForm.name = g.name
editForm.email = g.email || ''
editForm.phone = g.phone || ''
editForm.plus_ones = g.plus_ones || 0
}
async function saveEdit() {
if (!editing.value) return
savingEdit.value = true
try {
await useApi(`/events/${eventId}/guests/${editing.value.id}`, {
method: 'PATCH',
body: {
name: editForm.name.trim(),
// Empty string clears the field server-side.
email: editForm.email.trim(),
phone: editForm.phone.trim(),
plus_ones: editForm.plus_ones,
},
})
showToast({ kind: 'success', text: `Updated ${editForm.name.trim()}` })
editing.value = null
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not save changes') })
} finally {
savingEdit.value = false
}
}
const deleting = ref<Guest | null>(null)
const deletingInFlight = ref(false)
async function confirmDelete() {
if (!deleting.value) return
deletingInFlight.value = true
const name = deleting.value.name
try {
await useApi(`/events/${eventId}/guests/${deleting.value.id}`, { method: 'DELETE' })
deleting.value = null
await refresh()
showToast({ kind: 'success', text: `Removed ${name}` })
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not delete guest') })
} finally {
deletingInFlight.value = false
}
}
// RSVP edit history (Tier 2 Block A). Lazy-loaded — we only fetch revisions
// when the host clicks "History" on a guest, so a 200-row event list stays
// cheap. The current RSVP is in historyData.rsvp; revisions are newest-first.
interface RSVPRevision {
id: string
rsvp_id: string
prev_response: 'attending' | 'declined' | 'maybe'
prev_plus_ones: number
prev_dietary?: string | null
changed_at: string
}
interface RSVPHistoryResponse {
rsvp: {
response: 'attending' | 'declined' | 'maybe'
plus_ones: number
dietary_notes?: string | null
submitted_at: string
edit_count: number
} | null
revisions: RSVPRevision[]
}
const historyFor = ref<Guest | null>(null)
const historyData = ref<RSVPHistoryResponse | null>(null)
const historyLoading = ref(false)
const historyError = ref<string | null>(null)
async function openHistory(g: Guest) {
historyFor.value = g
historyData.value = null
historyError.value = null
historyLoading.value = true
try {
historyData.value = await useApi<RSVPHistoryResponse>(
`/events/${eventId}/guests/${g.id}/rsvp/history`,
)
} catch (e: any) {
historyError.value = useErrMessage(e, 'Could not load history')
} finally {
historyLoading.value = false
}
}
function closeHistory() {
historyFor.value = null
historyData.value = null
historyError.value = null
}
// Regenerate-link flow. The modal stays open after click so the host can
// see the new link copy succeed; closing returns to the list.
const regenerating = ref<Guest | null>(null)
const regenInFlight = ref<'resend' | 'copy' | null>(null)
async function regenerateAndResend() {
if (!regenerating.value) return
regenInFlight.value = 'resend'
try {
await useApi(`/events/${eventId}/guests/${regenerating.value.id}/tokens/rotate`, {
method: 'POST',
body: { send_email: true },
})
showToast({ kind: 'success', text: `New invitation sent to ${regenerating.value.email}` })
regenerating.value = null
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not resend invitation') })
} finally {
regenInFlight.value = null
}
}
async function regenerateAndCopy() {
if (!regenerating.value) return
regenInFlight.value = 'copy'
try {
const res = await useApi<IssuedToken>(
`/events/${eventId}/guests/${regenerating.value.id}/tokens/rotate`,
{ method: 'POST', body: { send_email: false } },
)
const url = res.invitation_link || rsvpUrl(res.token)
if (import.meta.client) await navigator.clipboard.writeText(url)
showToast({ kind: 'success', text: 'New link copied to clipboard' })
regenerating.value = null
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not generate link') })
} finally {
regenInFlight.value = null
}
}
// Esc closes any open modal — standard expectation for dialog UI.
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') return
if (editing.value || deleting.value || regenerating.value || historyFor.value || addingGuestOpen.value || importingOpen.value || whatsappOpen.value) {
editing.value = null
deleting.value = null
regenerating.value = null
closeHistory()
addingGuestOpen.value = false
importingOpen.value = false
whatsappOpen.value = false
}
}
if (import.meta.client) {
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}
// Toast state — auto-dismisses after a few seconds.
interface Toast { kind: 'success' | 'error'; text: string }
const toast = ref<Toast | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(t: Toast, ms = 5000) {
toast.value = t
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast.value = null }, ms)
}
// A guest is eligible for invitation if they don't already have a token.
function isEligible(g: Guest): boolean {
return !g.has_token
}
const eligibleGuests = computed(() => guests.value.filter(isEligible))
const eligibleIds = computed(() => new Set(eligibleGuests.value.map((g) => g.id)))
const selectedCount = computed(() => {
// Drop stale ids (guest may have just been deleted) by intersecting
// with the current eligible set.
let n = 0
selectedIds.value.forEach((id) => { if (eligibleIds.value.has(id)) n++ })
return n
})
const selectedWithEmail = computed(() => {
let n = 0
for (const g of eligibleGuests.value) {
if (selectedIds.value.has(g.id) && g.email) n++
}
return n
})
const selectedNoEmail = computed(() => selectedCount.value - selectedWithEmail.value)
const selectAllState = computed<'none' | 'some' | 'all'>(() => {
if (eligibleGuests.value.length === 0) return 'none'
if (selectedCount.value === 0) return 'none'
if (selectedCount.value === eligibleGuests.value.length) return 'all'
return 'some'
})
function isSelected(id: string): boolean {
return selectedIds.value.has(id)
}
function toggleGuest(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id); else next.add(id)
selectedIds.value = next
}
function toggleSelectAll() {
if (selectAllState.value === 'all') {
selectedIds.value = new Set()
} else {
selectedIds.value = new Set(eligibleGuests.value.map((g) => g.id))
}
}
function clearSelection() {
selectedIds.value = new Set()
}
async function runBulkSend() {
bulkSending.value = true
try {
const ids = Array.from(selectedIds.value).filter((id) => eligibleIds.value.has(id))
const res = await useApi<BulkInviteResult>(
`/events/${eventId}/guests/invitations/bulk`,
{ method: 'POST', body: { guest_ids: ids } },
)
// Hydrate per-guest tokens so the row-level copy-link icon works
// immediately, without waiting for a refresh+re-issue dance.
for (const t of res.tokens || []) {
issued.value[t.guest_id] = {
token: t.token,
token_id: '',
invitation_queued: t.invitation_queued,
invitation_link: t.invitation_link,
}
}
bulkConfirmOpen.value = false
clearSelection()
await refresh()
const parts: string[] = []
if (res.queued > 0) parts.push(`Sent ${res.queued} invitation${res.queued === 1 ? '' : 's'}`)
if (res.skipped_no_email > 0) parts.push(`${res.skipped_no_email} link${res.skipped_no_email === 1 ? '' : 's'} ready to copy`)
if (res.skipped_existing > 0) parts.push(`${res.skipped_existing} already invited`)
if (res.errors?.length) parts.push(`${res.errors.length} error${res.errors.length === 1 ? '' : 's'}`)
showToast({
kind: res.errors?.length ? 'error' : 'success',
text: parts.length ? parts.join(' · ') : 'Nothing to send.',
})
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Bulk send failed') })
} finally {
bulkSending.value = false
}
}
async function issueToken(guestId: string) {
issuing.value = guestId
try {
const res = await useApi<IssuedToken>(`/events/${eventId}/guests/${guestId}/tokens`, {
method: 'POST',
})
issued.value[guestId] = res
} catch (e) {
console.error(e)
} finally {
issuing.value = null
}
}
function rsvpUrl(token: string): string {
if (import.meta.server) return ''
return `${window.location.origin}/rsvp/${token}`
}
async function copyLink(guestId: string, token: string) {
if (!import.meta.client) return
try {
await navigator.clipboard.writeText(rsvpUrl(token))
copiedFor.value = guestId
if (copyResetTimer) clearTimeout(copyResetTimer)
copyResetTimer = setTimeout(() => {
if (copiedFor.value === guestId) copiedFor.value = null
}, 1500)
} catch (e) {
console.error('clipboard copy failed', e)
}
}
// Live monitor — RSVP + fraud feed via WS
interface FeedItem {
type: string
ts: string
text: string
band?: string
}
const feed = ref<FeedItem[]>([])
const wsConnected = ref(false)
const guestById = computed(() => Object.fromEntries(guests.value.map((g) => [g.id, g])))
function pushFeed(item: FeedItem) {
feed.value = [item, ...feed.value].slice(0, 50)
}
let stopWS: (() => void) | null = null
onMounted(() => {
stopWS = useEventWS(eventId, (msg: WSMessage) => {
wsConnected.value = true
if (msg.type === 'rsvp.confirmed') {
const g = guestById.value[msg.payload.guest_id]
pushFeed({
type: msg.type,
ts: msg.timestamp,
text: `${g?.name || 'Guest'}${msg.payload.response} (+${msg.payload.plus_ones || 0})`,
})
// Refresh stats and per-guest status so the counts reflect the new RSVP.
refresh().catch(() => {})
} else if (msg.type === 'fraud.scored') {
const g = guestById.value[msg.payload.guest_id]
const who = g?.name || 'A guest'
const band = (msg.payload.risk as string) || 'low'
// Plain-English text per band — no raw scores, no jargon.
const friendlyText: Record<string, string> = {
low: `${who} opened their invitation — looks normal.`,
medium: `${who}'s access looks a bit unusual — worth a glance.`,
high: `${who}'s access looks suspicious — review when you can.`,
block: `${who}'s access was blocked.`,
}
pushFeed({
type: msg.type,
ts: msg.timestamp,
text: friendlyText[band] || `${who}'s invitation was checked.`,
band,
})
}
})
})
onUnmounted(() => stopWS?.())
function fmtTime(iso: string): string {
try { return new Date(iso).toLocaleTimeString() } catch { return iso }
}
function fmtDate(iso: string): string {
try { return new Date(iso).toLocaleString() } catch { return iso }
}
// Friendly pill label for an access-check item (replaces "fraud · {band}").
function checkLabel(band?: string): string {
switch (band) {
case 'low': return 'Verified'
case 'medium': return 'Review'
case 'high': return 'Suspicious'
case 'block': return 'Blocked'
default: return 'Checked'
}
}
</script>
<template>
<section v-if="loading" class="text-sm text-zinc-500">Loading</section>
<section v-else-if="!event" class="card">Event not found.</section>
<section v-else class="space-y-8">
<div>
<NuxtLink to="/dashboard" class="mb-2 inline-block text-sm text-zinc-400 hover:text-zinc-200">
Back to dashboard
</NuxtLink>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">{{ event.name }}</h1>
<span class="badge bg-zinc-800 text-zinc-300">{{ event.status }}</span>
</div>
<p class="text-sm text-zinc-400">{{ event.venue }} · {{ fmtDate(event.event_date) }}</p>
</div>
<div class="grid gap-8 lg:grid-cols-[2fr_1fr]">
<!-- Primary content: the guest list. Add / Import live as modals
triggered from the toolbar keeps secondary tools out of the
way until the host needs them. -->
<div class="space-y-6">
<div class="card">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 class="text-lg font-semibold">Guests</h2>
<div class="flex items-center gap-2">
<button
type="button"
class="btn-primary inline-flex items-center gap-1.5 !px-3 !py-1.5 text-sm"
@click="openAddGuest"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" />
</svg>
Add guest
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-800"
@click="importingOpen = true"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Import CSV
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-800"
title="Send personal invitations via WhatsApp"
@click="whatsappOpen = true"
>
<!-- Chat-bubble glyph readable without trademark friction;
the label spells the destination. -->
<svg class="h-4 w-4 text-emerald-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18 5.05A9 9 0 002 10.5a8.96 8.96 0 001.46 4.92L2 19l3.74-1.45A8.97 8.97 0 0010 19.05a9 9 0 008-13.5zM10 17.55a7.05 7.05 0 01-3.6-.99l-.26-.15-2.22.86.84-2.16-.17-.27a7.05 7.05 0 119.16 2.71 7.04 7.04 0 01-3.75 0z" clip-rule="evenodd" />
</svg>
WhatsApp
</button>
<button class="text-xs text-zinc-400 hover:text-zinc-200" @click="refresh">Refresh</button>
</div>
</div>
<!-- Compact segmented filter -->
<div v-if="guests.length > 0" class="mb-3 inline-flex flex-wrap gap-1 rounded-lg border border-zinc-800 bg-zinc-950 p-1 text-xs">
<button
v-for="bucket in (['all','attending','declined','maybe','pending'] as const)"
:key="bucket"
class="rounded-md px-2.5 py-1 transition"
:class="filter === bucket
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200'"
@click="filter = bucket"
>
<span class="capitalize">{{ bucket }}</span>
<span class="ml-1.5 tabular-nums text-zinc-500">{{ bucket === 'all' ? stats.total : stats[bucket] }}</span>
</button>
</div>
<!-- Selection toolbar visually elevated above the list. The
left padding is intentionally identical to the row padding
(px-3) so the master checkbox shares a column with every
row checkbox, Gmail-style. -->
<div
v-if="eligibleGuests.length > 0"
class="mb-5 flex items-center justify-between gap-3 rounded-lg border py-3 pl-3 pr-4 shadow-sm transition"
:class="selectedCount > 0
? 'border-brand-600/70 bg-brand-500/10 shadow-brand-500/10'
: 'border-zinc-700 bg-zinc-800/70'"
>
<label class="flex cursor-pointer items-center gap-3 text-sm text-zinc-100">
<span class="flex h-5 w-5 shrink-0 items-center justify-center">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer accent-brand-500"
:checked="selectAllState === 'all'"
:indeterminate.prop="selectAllState === 'some'"
:aria-label="selectAllState === 'all' ? 'Deselect all' : 'Select all eligible guests'"
@change="toggleSelectAll"
/>
</span>
<!-- Count badge circular pill with current selection / eligible count -->
<span
class="inline-flex h-6 min-w-[2rem] items-center justify-center rounded-full px-2 text-xs font-semibold tabular-nums"
:class="selectedCount > 0
? 'bg-brand-500 text-zinc-950'
: 'bg-zinc-700 text-zinc-200'"
>
{{ selectedCount > 0 ? selectedCount : eligibleGuests.length }}
</span>
<span class="text-zinc-200">
{{
selectedCount === 0
? `eligible to invite`
: selectedCount === eligibleGuests.length
? `selected · all eligible guests`
: `selected of ${eligibleGuests.length}`
}}
</span>
</label>
<button
class="btn-primary disabled:cursor-not-allowed disabled:opacity-30"
:disabled="selectedCount === 0 || bulkSending"
@click="bulkConfirmOpen = true"
>
{{ bulkSending ? 'Sending…' : 'Send invitations' }}
<span v-if="selectedCount > 0" aria-hidden="true"></span>
</button>
</div>
<!-- Confirmation banner (only on demand; no longer permanently visible) -->
<div v-if="bulkConfirmOpen" class="mb-3 rounded-lg border border-zinc-700 bg-zinc-900 p-3 text-sm">
<p class="mb-1">
Email <span class="font-semibold text-zinc-100">{{ selectedWithEmail }}</span>
of the <span class="font-semibold text-zinc-100">{{ selectedCount }}</span> selected
{{ selectedCount === 1 ? 'guest' : 'guests' }}?
<span v-if="selectedNoEmail > 0" class="text-zinc-500">
The other {{ selectedNoEmail }} will get links you can copy.
</span>
</p>
<div class="mt-2 flex items-center gap-2">
<button class="btn-primary !px-3 !py-1.5 text-xs" :disabled="bulkSending" @click="runBulkSend">
{{ bulkSending ? 'Sending' : 'Confirm send' }}
</button>
<button class="text-xs text-zinc-400 hover:text-zinc-200" :disabled="bulkSending" @click="bulkConfirmOpen = false">Cancel</button>
</div>
</div>
<!-- Empty states -->
<div v-if="guests.length === 0" class="rounded-lg border border-dashed border-zinc-800 bg-zinc-900/30 px-4 py-8 text-center">
<p class="text-sm text-zinc-300">No guests yet.</p>
<p class="mt-1 text-xs text-zinc-500">Add one above, or drop in a spreadsheet using the import card.</p>
</div>
<div v-else-if="filteredGuests.length === 0" class="rounded-lg border border-dashed border-zinc-800 bg-zinc-900/30 px-4 py-6 text-center text-sm text-zinc-500">
No guests match this filter.
</div>
<!-- Grouped list. Each group renders its own <ul> so the
horizontal dividers stop at the group boundary. The header
sticks to the viewport top while its rows are in view
standard pattern for long lists (Linear, Apple Mail). -->
<div v-else class="space-y-4">
<section v-for="grp in groupedGuests" :key="grp.key">
<h3
v-if="grp.label"
class="sticky top-0 z-10 -mx-5 mb-1 flex items-center gap-2 bg-zinc-900/95 px-5 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-zinc-400 backdrop-blur"
>
{{ grp.label }}
<span class="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-zinc-300">
{{ grp.guests.length }}
</span>
</h3>
<ul class="divide-y divide-zinc-800/70">
<li
v-for="g in grp.guests"
:key="g.id"
class="group flex items-center gap-3 py-2.5 pl-3 pr-4 transition"
:class="isEligible(g) && isSelected(g.id) ? 'bg-brand-500/[0.04]' : ''"
:tabindex="isEligible(g) ? 0 : -1"
:role="isEligible(g) ? 'button' : undefined"
:aria-pressed="isEligible(g) ? isSelected(g.id) : undefined"
@click="isEligible(g) && toggleGuest(g.id)"
@keydown.space.prevent="isEligible(g) && toggleGuest(g.id)"
@keydown.enter.prevent="isEligible(g) && toggleGuest(g.id)"
>
<!-- Lead column 20px slot. Holds either the checkbox
(eligible rows) or a state-specific status icon. -->
<span class="flex h-5 w-5 shrink-0 items-center justify-center">
<input
v-if="isEligible(g)"
type="checkbox"
class="h-4 w-4 cursor-pointer accent-brand-500"
:checked="isSelected(g.id)"
:aria-label="`Select ${g.name}`"
@click.stop
@change="toggleGuest(g.id)"
/>
<!-- Invited, awaiting reply paper-plane (sent + flying) -->
<svg
v-else-if="!g.rsvp_response"
class="h-5 w-5 text-brand-400/80" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
>
<title>{{ g.name }} has been invited</title>
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
</svg>
<!-- Attending check-circle solid (brand green) -->
<svg
v-else-if="g.rsvp_response === 'attending'"
class="h-5 w-5 text-brand-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
>
<title>{{ g.name }} is attending</title>
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<!-- Maybe question-mark-circle solid (amber) -->
<svg
v-else-if="g.rsvp_response === 'maybe'"
class="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
>
<title>{{ g.name }} replied maybe</title>
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9z" clip-rule="evenodd" />
</svg>
<!-- Declined x-circle solid (neutral) -->
<svg
v-else-if="g.rsvp_response === 'declined'"
class="h-5 w-5 text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
>
<title>{{ g.name }} declined</title>
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</span>
<!-- Name + email primary identity. -->
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-zinc-100">{{ g.name }}</div>
<div class="truncate text-xs text-zinc-500">
{{ g.email || (g.phone ? g.phone : 'no contact details') }}
</div>
</div>
<!-- Status slot: fixed-width, right-aligned. Without
this the variable-length pills (Pending vs
Attending +2) land at different x-positions row to
row. The flagged badge sits BEFORE the status so
the main status badge's right edge always anchors
to the slot's right edge. -->
<div class="flex w-28 shrink-0 items-center justify-end gap-1.5">
<span
v-if="g.rsvp_risk_score != null && g.rsvp_risk_score >= 60"
class="badge-high"
:title="`Risk score ${g.rsvp_risk_score}`"
>flagged</span>
<span v-if="g.rsvp_response === 'attending'" class="badge-low">Attending<span v-if="(g.rsvp_plus_ones ?? 0) > 0" class="ml-1 text-brand-200/80">+{{ g.rsvp_plus_ones }}</span></span>
<span v-else-if="g.rsvp_response === 'declined'" class="badge bg-zinc-800 text-zinc-400">Declined</span>
<span v-else-if="g.rsvp_response === 'maybe'" class="badge-medium">Maybe</span>
<span
v-else-if="g.has_token || issued[g.id]"
class="badge bg-brand-900/30 text-brand-300"
:title="issued[g.id]?.invitation_queued
? 'Invitation email queued for delivery'
: 'Invited — copy the link to share manually'"
>Invited</span>
<span v-else class="badge bg-zinc-800/40 text-zinc-500">Pending</span>
</div>
<!-- Row actions: fixed-width slot keeps the badge
column (above) aligned across rows regardless of
which icons render. Right-aligned content so the
outer edge of every row's action area lines up. -->
<div class="flex w-32 shrink-0 items-center justify-end gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
<template v-if="isEligible(g)">
<button
type="button"
class="rounded-md p-1.5 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
title="Edit guest"
:aria-label="`Edit ${g.name}`"
@click.stop="openEdit(g)"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
<button
type="button"
class="rounded-md p-1.5 text-zinc-400 transition hover:bg-red-500/10 hover:text-red-300"
title="Delete guest"
:aria-label="`Delete ${g.name}`"
@click.stop="deleting = g"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</button>
</template>
<template v-else-if="!g.rsvp_response">
<!-- Copy link (only when invitation wasn't emailed AND
we still hold the raw token from this session
the host can SMS the guest manually). -->
<button
v-if="issued[g.id] && !issued[g.id].invitation_queued"
type="button"
class="rounded-md p-1.5 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
:title="copiedFor === g.id ? 'Copied' : 'Copy invitation link'"
:aria-label="copiedFor === g.id ? 'Copied' : 'Copy invitation link'"
@click.stop="copyLink(g.id, issued[g.id].token)"
>
<svg v-if="copiedFor !== g.id" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M7 3a2 2 0 00-2 2v8a2 2 0 002 2h6a2 2 0 002-2V7.414A2 2 0 0014.414 6L12 3.586A2 2 0 0010.586 3H7z" />
<path d="M5 7H4a2 2 0 00-2 2v8a2 2 0 002 2h6a2 2 0 002-2v-1H5V7z" />
</svg>
<svg v-else class="h-4 w-4 text-brand-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M16.704 5.296a1 1 0 010 1.408l-8 8a1 1 0 01-1.408 0l-4-4a1 1 0 011.408-1.408L8 12.592l7.296-7.296a1 1 0 011.408 0z" clip-rule="evenodd" />
</svg>
</button>
<!-- Regenerate link labelled pill rather than
an icon-only button. The action is app-specific
and uncommon, so industry-standard UX is to
spell it out for clarity over compactness. -->
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-zinc-700 px-2 py-1 text-xs font-medium text-zinc-300 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
title="Invalidate the old invitation and create a fresh one"
:aria-label="`Generate a new invitation link for ${g.name}`"
@click.stop="regenerating = g"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
</svg>
New link
</button>
</template>
<template v-else-if="g.rsvp_response">
<!-- Responded guests: show edit history. Useful when
"attending" silently flips to "declined" and the
host wants to know when. -->
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-zinc-700 px-2 py-1 text-xs font-medium text-zinc-300 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
:title="`View the edit trail for ${g.name}`"
:aria-label="`View RSVP history for ${g.name}`"
@click.stop="openHistory(g)"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM10 4a1 1 0 011 1v5l3 1a1 1 0 11-.5 1.93l-3.5-1.16A1 1 0 019 10.83V5a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
History
</button>
</template>
</div>
</li>
</ul>
</section>
</div>
</div>
</div>
<!-- Live monitor -->
<aside class="card flex max-h-[80vh] flex-col">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-semibold">Live monitor</h2>
<span
class="badge"
:class="wsConnected ? 'bg-brand-900/40 text-brand-300' : 'bg-zinc-800 text-zinc-400'"
>
<span class="mr-1 inline-block h-1.5 w-1.5 rounded-full" :class="wsConnected ? 'bg-brand-400' : 'bg-zinc-500'"></span>
{{ wsConnected ? 'live' : 'connecting' }}
</span>
</div>
<p class="mb-3 text-xs text-zinc-500">
Guest responses and security alerts, the moment they happen.
</p>
<div class="flex-1 overflow-y-auto">
<div v-if="feed.length === 0" class="rounded-lg border border-zinc-800/50 bg-zinc-900/30 p-5 text-center">
<p class="mb-1 text-sm font-medium text-zinc-400">All quiet for now</p>
<p class="text-xs leading-relaxed text-zinc-600">
Once guests start responding, their RSVPs and any<br />
security alerts will appear here in real time.
</p>
</div>
<ul v-else class="space-y-2">
<li
v-for="(item, i) in feed"
:key="`${item.type}-${item.ts}-${i}`"
class="rounded border border-zinc-800 bg-zinc-950 p-3 text-sm"
>
<div class="mb-1 flex items-center justify-between text-xs">
<span class="font-mono text-zinc-500">{{ fmtTime(item.ts) }}</span>
<span
v-if="item.type === 'fraud.scored'"
:class="`badge-${item.band || 'low'}`"
>{{ checkLabel(item.band) }}</span>
<span v-else class="badge-low">RSVP</span>
</div>
<p class="text-zinc-200">{{ item.text }}</p>
</li>
</ul>
</div>
</aside>
</div>
<!-- ===== Modals ===== -->
<!-- All modals share the same pattern: backdrop click + Esc close,
role=dialog + aria-modal, primary action on the right.
Auto-focus on first input (Add) or safest button (Delete). -->
<!-- Add guest -->
<Teleport to="body">
<div
v-if="addingGuestOpen"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="addingGuestOpen = false"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="add-guest-title"
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
>
<h3 id="add-guest-title" class="mb-1 text-base font-semibold">Add a guest</h3>
<p class="mb-4 text-xs text-zinc-500">
Adding a guest doesn't send an invitation yet — you'll do that from the list.
</p>
<form class="space-y-3" @submit.prevent="addGuest">
<div>
<label class="label">Name</label>
<input
ref="addGuestNameRef"
v-model="newGuest.name"
class="input"
placeholder="Mira Patel"
required
autofocus
/>
</div>
<div>
<label class="label">Email</label>
<input v-model="newGuest.email" type="email" class="input" placeholder="optional" />
</div>
<div>
<label class="label">Phone</label>
<PhoneInput v-model="newGuest.phone" />
</div>
<div>
<label class="label mb-1">Plus-ones allowed</label>
<div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-950">
<button
type="button"
class="flex h-10 w-11 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
:disabled="newGuest.plus_ones <= 0"
@click="newGuest.plus_ones = Math.max(0, newGuest.plus_ones - 1)"
></button>
<span class="flex-1 text-center font-semibold tabular-nums text-zinc-100">{{ newGuest.plus_ones }}</span>
<button
type="button"
class="flex h-10 w-11 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
@click="newGuest.plus_ones++"
>+</button>
</div>
</div>
<div class="flex items-center justify-end gap-2 pt-2">
<button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" :disabled="addingGuest" @click="addingGuestOpen = false">Cancel</button>
<button type="submit" class="btn-primary" :disabled="addingGuest || !newGuest.name.trim()">
{{ addingGuest ? 'Adding…' : 'Add guest' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
<!-- Import CSV -->
<Teleport to="body">
<div
v-if="importingOpen"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="importingOpen = false"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="import-csv-title"
class="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-lg border border-zinc-800 bg-zinc-900 shadow-2xl"
>
<div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3">
<h3 id="import-csv-title" class="text-base font-semibold">Import guests from a spreadsheet</h3>
<button
type="button"
class="rounded-md p-1 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
aria-label="Close"
@click="importingOpen = false"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="overflow-y-auto px-5 py-4">
<CsvImportCard :event-id="eventId" @imported="handleImported" />
</div>
</div>
</div>
</Teleport>
<!-- WhatsApp send wizard -->
<Teleport to="body">
<div
v-if="whatsappOpen"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="whatsappOpen = false"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="wa-title"
class="flex max-h-[85vh] w-full max-w-xl flex-col rounded-lg border border-zinc-800 bg-zinc-900 shadow-2xl"
>
<div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3">
<div>
<h3 id="wa-title" class="text-base font-semibold">Send invitations via WhatsApp</h3>
<p class="mt-0.5 text-xs text-zinc-500">
Each tap opens WhatsApp with a personal link pre-filled you hit Send inside the app.
Numbers must include the country code (e.g. <code class="font-mono text-zinc-400">+233</code>).
</p>
</div>
<button
type="button"
class="rounded-md p-1 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
aria-label="Close"
@click="whatsappOpen = false"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<!-- Progress bar -->
<div v-if="waEligible.length > 0" class="border-b border-zinc-800 px-5 py-3">
<div class="mb-1.5 flex items-center justify-between text-xs">
<span class="text-zinc-300">
<span class="font-semibold tabular-nums text-zinc-100">{{ waSentCount }}</span>
of
<span class="font-semibold tabular-nums text-zinc-100">{{ waEligible.length }}</span>
sent
</span>
<button
v-if="waSentCount > 0"
type="button"
class="text-zinc-500 hover:text-zinc-300"
@click="resetWaSent"
>Reset progress</button>
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-zinc-800">
<div
class="h-full bg-emerald-500 transition-all"
:style="{ width: `${waEligible.length === 0 ? 0 : (waSentCount / waEligible.length) * 100}%` }"
></div>
</div>
</div>
<!-- List -->
<div class="overflow-y-auto px-5 py-3">
<div v-if="waEligible.length === 0" class="py-8 text-center text-sm text-zinc-400">
No guests have a phone number on file.
<p class="mt-1 text-xs text-zinc-500">Edit a guest to add their number, then come back here.</p>
</div>
<ul v-else class="divide-y divide-zinc-800/70">
<li v-for="g in waEligible" :key="g.id" class="flex items-center justify-between gap-3 py-2.5">
<div class="min-w-0">
<div class="truncate text-sm font-medium text-zinc-100">{{ g.name }}</div>
<div class="flex items-center gap-1.5 truncate text-xs">
<!-- Inline warning when the number isn't routable by wa.me -->
<svg
v-if="!isLikelyE164(g.phone)"
class="h-3.5 w-3.5 shrink-0 text-amber-400"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
>
<title>Missing country code — WhatsApp can't open this number</title>
<path fill-rule="evenodd" d="M8.485 3.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 3.495zM10 8a1 1 0 01.993.883L11 9v3a1 1 0 01-1.993.117L9 12V9a1 1 0 011-1zm0 6a1 1 0 110 2 1 1 0 010-2z" clip-rule="evenodd" />
</svg>
<span class="truncate" :class="isLikelyE164(g.phone) ? 'text-zinc-500' : 'text-amber-300'">
{{ g.phone }}
</span>
</div>
</div>
<!-- Bad phone Fix button. Good phone Send / Resend. -->
<button
v-if="!isLikelyE164(g.phone)"
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-amber-700/60 bg-amber-500/10 px-3 py-1.5 text-xs font-medium text-amber-200 transition hover:bg-amber-500/20"
@click="fixPhoneFromWizard(g)"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
Fix number
</button>
<button
v-else
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition disabled:opacity-50"
:class="waSent.has(g.id)
? 'border border-zinc-700 text-zinc-300 hover:border-emerald-600 hover:bg-emerald-500/10'
: 'bg-emerald-500 text-zinc-950 hover:bg-emerald-400'"
:disabled="waSending === g.id"
@click="sendOneViaWhatsapp(g)"
>
<template v-if="waSending === g.id">Opening</template>
<template v-else-if="waSent.has(g.id)">
<svg class="h-3.5 w-3.5 text-emerald-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M16.704 5.296a1 1 0 010 1.408l-8 8a1 1 0 01-1.408 0l-4-4a1 1 0 011.408-1.408L8 12.592l7.296-7.296a1 1 0 011.408 0z" clip-rule="evenodd" />
</svg>
Sent · resend
</template>
<template v-else>Send</template>
</button>
</li>
</ul>
</div>
<!-- Footer hint -->
<div
v-if="waNoPhoneCount > 0"
class="border-t border-zinc-800 bg-zinc-900/50 px-5 py-2.5 text-xs text-zinc-500"
>
<span aria-hidden="true"></span>
{{ waNoPhoneCount }} {{ waNoPhoneCount === 1 ? 'guest has' : 'guests have' }} no phone number invite them via email or import their numbers.
</div>
</div>
</div>
</Teleport>
<!-- Edit guest -->
<Teleport to="body">
<div
v-if="editing"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="editing = null"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="edit-guest-title"
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
>
<h3 id="edit-guest-title" class="mb-1 text-base font-semibold">Edit guest</h3>
<p class="mb-4 text-xs text-zinc-500">Adjust contact details before sending the invitation.</p>
<form class="space-y-3" @submit.prevent="saveEdit">
<div>
<label class="label">Name</label>
<input v-model="editForm.name" class="input" required maxlength="255" />
</div>
<div>
<label class="label">Email</label>
<input v-model="editForm.email" type="email" class="input" placeholder="optional" />
</div>
<div>
<label class="label">Phone</label>
<PhoneInput v-model="editForm.phone" />
</div>
<div>
<label class="label">Plus-ones</label>
<input v-model.number="editForm.plus_ones" type="number" min="0" class="input" />
</div>
<div class="flex items-center justify-end gap-2 pt-2">
<button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" :disabled="savingEdit" @click="editing = null">Cancel</button>
<button type="submit" class="btn-primary" :disabled="savingEdit || !editForm.name.trim()">
{{ savingEdit ? 'Saving…' : 'Save changes' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
<!-- Delete guest -->
<Teleport to="body">
<div
v-if="deleting"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="deleting = null"
>
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-guest-title"
aria-describedby="delete-guest-desc"
class="w-full max-w-sm rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
>
<h3 id="delete-guest-title" class="mb-1 text-base font-semibold">Remove guest?</h3>
<p id="delete-guest-desc" class="mb-4 text-sm text-zinc-400">
<span class="text-zinc-200">{{ deleting.name }}</span> will be removed from this event.
This can't be undone.
</p>
<div class="flex items-center justify-end gap-2">
<button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" :disabled="deletingInFlight" @click="deleting = null">Cancel</button>
<button
type="button"
class="rounded-md bg-red-500/90 px-3 py-1.5 text-sm font-medium text-white shadow-sm transition hover:bg-red-500 disabled:opacity-40"
:disabled="deletingInFlight"
@click="confirmDelete"
>
{{ deletingInFlight ? 'Removing' : 'Remove guest' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Regenerate invitation link -->
<Teleport to="body">
<div
v-if="regenerating"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="regenerating = null"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="regen-title"
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
>
<h3 id="regen-title" class="mb-1 text-base font-semibold">Generate a new invitation link?</h3>
<p class="mb-4 text-sm text-zinc-400">
The current link for
<span class="text-zinc-200">{{ regenerating.name }}</span>
will stop working immediately. Choose how to deliver the new one.
</p>
<div class="space-y-2">
<button
type="button"
class="flex w-full items-center justify-between rounded-md border border-zinc-700 bg-zinc-950 px-3 py-3 text-left transition hover:border-brand-600 hover:bg-zinc-900 disabled:opacity-50"
:disabled="!regenerating.email || regenInFlight !== null"
@click="regenerateAndResend"
>
<span>
<span class="block text-sm font-medium text-zinc-100">Resend via email</span>
<span class="block text-xs text-zinc-500">
{{ regenerating.email
? `Sends a fresh branded invitation to ${regenerating.email}.`
: 'No email on file add one in Edit to enable this option.' }}
</span>
</span>
<span class="text-xs text-zinc-500">{{ regenInFlight === 'resend' ? 'Sending' : '' }}</span>
</button>
<button
type="button"
class="flex w-full items-center justify-between rounded-md border border-zinc-700 bg-zinc-950 px-3 py-3 text-left transition hover:border-brand-600 hover:bg-zinc-900 disabled:opacity-50"
:disabled="regenInFlight !== null"
@click="regenerateAndCopy"
>
<span>
<span class="block text-sm font-medium text-zinc-100">Copy new link</span>
<span class="block text-xs text-zinc-500">
Copies the new URL to your clipboard so you can share it manually.
</span>
</span>
<span class="text-xs text-zinc-500">{{ regenInFlight === 'copy' ? 'Copying' : '' }}</span>
</button>
</div>
<div class="mt-4 flex items-center justify-end">
<button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" :disabled="regenInFlight !== null" @click="regenerating = null">Cancel</button>
</div>
</div>
</div>
</Teleport>
<!-- RSVP edit history (Tier 2 Block A) -->
<Teleport to="body">
<div
v-if="historyFor"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="closeHistory"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="history-title"
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
>
<h3 id="history-title" class="mb-1 text-base font-semibold">
RSVP history — {{ historyFor.name }}
</h3>
<p class="mb-4 text-xs text-zinc-500">
Each edit replaces the previous response. The trail below is read-only.
</p>
<div v-if="historyLoading" class="py-6 text-center text-sm text-zinc-500">Loading…</div>
<div v-else-if="historyError" class="rounded-md border border-red-900/60 bg-red-950/30 p-3 text-sm text-red-300">
{{ historyError }}
</div>
<div v-else-if="historyData && historyData.rsvp" class="space-y-3">
<div class="rounded-md border border-brand-900/60 bg-brand-950/20 p-3">
<div class="mb-1 flex items-center justify-between text-xs text-brand-300">
<span class="font-medium uppercase tracking-wider">Current</span>
<span class="text-zinc-500">{{ fmtTime(historyData.rsvp.submitted_at) }}</span>
</div>
<p class="text-sm text-zinc-200">
<strong class="capitalize">{{ historyData.rsvp.response }}</strong>
<span v-if="historyData.rsvp.plus_ones > 0"> · +{{ historyData.rsvp.plus_ones }} plus-ones</span>
<span v-if="historyData.rsvp.dietary_notes"> · {{ historyData.rsvp.dietary_notes }}</span>
</p>
<p class="mt-1 text-xs text-zinc-500">
{{ historyData.rsvp.edit_count }} edit{{ historyData.rsvp.edit_count === 1 ? '' : 's' }}
</p>
</div>
<div v-if="historyData.revisions.length === 0" class="py-3 text-center text-xs text-zinc-500">
No edits yet.
</div>
<ul v-else class="space-y-2">
<li
v-for="rev in historyData.revisions"
:key="rev.id"
class="rounded-md border border-zinc-800 bg-zinc-950 p-3"
>
<div class="mb-1 flex items-center justify-between text-xs text-zinc-500">
<span>was</span>
<span>{{ fmtTime(rev.changed_at) }}</span>
</div>
<p class="text-sm text-zinc-300">
<strong class="capitalize">{{ rev.prev_response }}</strong>
<span v-if="rev.prev_plus_ones > 0"> · +{{ rev.prev_plus_ones }} plus-ones</span>
<span v-if="rev.prev_dietary"> · {{ rev.prev_dietary }}</span>
</p>
</li>
</ul>
</div>
<div v-else class="py-3 text-center text-sm text-zinc-500">
No RSVP has been submitted yet.
</div>
<div class="mt-5 flex items-center justify-end">
<button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" @click="closeHistory">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- Toast: bulk-send result. Fades after ~5s; click to dismiss. -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<button
v-if="toast"
type="button"
class="fixed bottom-6 right-6 z-50 max-w-sm rounded-lg border px-4 py-3 text-left text-sm shadow-lg backdrop-blur"
:class="toast.kind === 'success'
? 'border-brand-700/60 bg-brand-950/90 text-brand-100'
: 'border-red-800/60 bg-red-950/90 text-red-100'"
:aria-label="`Dismiss notification: ${toast.text}`"
@click="toast = null"
>
<span aria-hidden="true" class="mr-2">{{ toast.kind === 'success' ? '' : '!' }}</span>
{{ toast.text }}
</button>
</Transition>
</section>
</template>