dc840bfc14
The Communications surface. Hosts can schedule custom broadcasts to a
chosen audience (everyone / attending / pending / declined / maybe),
edit or cancel anything that hasn't fired, and review delivery
outcomes. Four auto-reminders are pre-seeded on every new event:
7-day, 3-day last call, 1-day, and day-of.
Schema (migration 0012)
- scheduled_messages — one row per message envelope, with status
walking draft -> scheduled -> sending -> sent (or cancelled/failed).
Partial index on (send_at) WHERE status='scheduled' for the
scheduler poll; per-event index for the Communications tab list.
- message_deliveries — per-recipient outcomes so a partial-failure
batch doesn't lose the rows that did succeed.
Domain
- MessageAudience / MessageChannel / MessageStatus enums
- SeedAutoReminders helper that returns four canonical reminder rows
for a given event_date, skipping any whose send_at would land in
the past (events created close to the date)
Storage
- MessageRepo: Create / CreateBatch / Get / ListByEvent / Update
(locks the row and refuses unless status is draft|scheduled) /
Cancel / PromoteToScheduled (the send-now path) / ListDue /
ClaimForSending (atomic guard against two replicas double-sending) /
MarkSent / MarkFailed / RecordDelivery / DeliveryStats /
LoadRecipients (audience-filtered guest list) / CountRecipients
- EventRepo.Create now seeds auto-reminders in the same transaction
that inserts the event and its owner collaborator row
API (all editor+, except recipient-count which is viewer+)
- GET /events/{id}/messages
- GET /events/{id}/messages/recipient-count?audience=...
- POST /events/{id}/messages (draft / schedule / send-now)
- PATCH /events/{id}/messages/{message_id}
- POST /events/{id}/messages/{message_id}/send-now
- DELETE /events/{id}/messages/{message_id}
Scheduler worker (cmd/notifier)
- New file scheduler.go: polls ListDue every 30s, claims each row
atomically (ClaimForSending uses a status=scheduled guard so two
notifier replicas don't double-send), renders subject and body
per recipient with the {{guest_name}} / {{event_name}} /
{{event_date}} / {{venue}} / {{rsvp_link}} placeholders, sends via
the existing GuestEmailDispatcher (Resend > SMTP > SES > log
stub, same picker as the API), records each delivery row.
Frontend
- New CommunicationsCard.vue with compose form (audience + channel +
subject + body + send-mode radios), live "X guests will receive
this" recipient-count preview, and three sub-tabs for Scheduled /
Sent / Cancelled. Per-message Send-now and Cancel actions for
draft/scheduled rows. Friendly labels for auto-seeded reminders
("1-day reminder", "Day-of reminder") so the slugs never leak.
- New top-level tab "Communications" on the event-detail page,
between Collaborators and Branding.
Tests
- TestAutoReminderSeeding confirms a future-dated event lands the
four canonical reminders in scheduled state.
- TestComposeAndEditMessage walks draft -> patch -> send-now ->
cancel and asserts the conflict on PATCH-after-cancel.
- TestRecipientCountAudienceFilter seeds a known guest mix and
checks every audience preset returns the right count.
- Full integration suite passes (~177s).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1696 lines
72 KiB
Vue
1696 lines
72 KiB
Vue
<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
|
||
// Tier 2 Block C — caller's role on this event. The dashboard branches
|
||
// UI affordances off this rather than the legacy host_id check.
|
||
your_role?: 'owner' | 'editor' | 'viewer'
|
||
}
|
||
|
||
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)
|
||
|
||
// Tab state — only one section is mounted at a time so the page stops
|
||
// being a giant scroll of cards. Hash-driven so deep links + browser
|
||
// back/forward Just Work. Order: Guests (daily workflow) → setup tabs
|
||
// (Collaborators + Branding, configured once) → Analytics (results,
|
||
// checked periodically). The two action-y tabs anchor the ends; setup
|
||
// clusters in the middle.
|
||
type EventTab = 'guests' | 'collaborators' | 'communications' | 'branding' | 'analytics' | 'gate'
|
||
const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate']
|
||
function tabFromHash(): EventTab {
|
||
if (import.meta.client) {
|
||
const h = window.location.hash.replace('#', '') as EventTab
|
||
if (validTabs.includes(h)) return h
|
||
// Block C / preview alias: older share links used #team.
|
||
if (h === ('team' as EventTab)) return 'collaborators'
|
||
// Block G rebrand: #security → #gate so old links don't 404.
|
||
if (h === ('security' as EventTab)) return 'gate'
|
||
}
|
||
return 'guests'
|
||
}
|
||
const activeTab = ref<EventTab>(tabFromHash())
|
||
function setTab(t: EventTab) {
|
||
activeTab.value = t
|
||
if (import.meta.client) {
|
||
// Update the URL hash without triggering a router scroll-to-top.
|
||
history.replaceState(null, '', `#${t}`)
|
||
}
|
||
}
|
||
if (import.meta.client) {
|
||
onMounted(() => {
|
||
window.addEventListener('hashchange', () => { activeTab.value = tabFromHash() })
|
||
})
|
||
}
|
||
|
||
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 events
|
||
</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>
|
||
|
||
<!-- Tab nav — segmented-control style: a single bordered container
|
||
with a brand-tinted "lifted" state for the active tab. Stronger
|
||
visual weight than underlined tabs, no overflow tricks (the
|
||
previous absolute-positioned underline was leaking a 1px vertical
|
||
scrollbar on the sticky container). Mobile wraps onto two rows
|
||
when needed rather than introducing horizontal scroll. -->
|
||
<nav
|
||
role="tablist"
|
||
aria-label="Event sections"
|
||
class="flex flex-wrap gap-1 rounded-lg border border-zinc-800 bg-zinc-900/40 p-1"
|
||
>
|
||
<button
|
||
v-for="t in [
|
||
{ id: 'guests', label: 'Guests' },
|
||
{ id: 'collaborators', label: 'Collaborators' },
|
||
{ id: 'communications', label: 'Communications' },
|
||
{ id: 'branding', label: 'Branding' },
|
||
{ id: 'analytics', label: 'Analytics' },
|
||
{ id: 'gate', label: 'Gate' },
|
||
] as { id: EventTab, label: string }[]"
|
||
:key="t.id"
|
||
role="tab"
|
||
:aria-selected="activeTab === t.id"
|
||
:class="[
|
||
'flex-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium transition',
|
||
activeTab === t.id
|
||
? 'border border-brand-700/40 bg-brand-500/10 text-brand-200 shadow-sm'
|
||
: 'border border-transparent text-zinc-400 hover:bg-zinc-800/60 hover:text-zinc-100',
|
||
]"
|
||
@click="setTab(t.id)"
|
||
>
|
||
{{ t.label }}
|
||
</button>
|
||
</nav>
|
||
|
||
<div v-show="activeTab === 'guests'" 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>
|
||
|
||
<!-- Branding (Tier 2 Block D). Editor+ can change colours/logo/cover;
|
||
viewers see read-only. Mounted only when its tab is active so the
|
||
live preview computes against the current state, not stale data. -->
|
||
<div v-if="activeTab === 'branding' && event" class="mt-2">
|
||
<BrandingCard
|
||
:event-id="eventId"
|
||
:your-role="event.your_role"
|
||
:event-name="event.name"
|
||
:event-venue="event.venue"
|
||
:event-date="event.event_date"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Analytics (Tier 2 Block E). v-if (not v-show) so the GET fires
|
||
fresh on each tab visit — hosts revisit this tab specifically to
|
||
re-check numbers. -->
|
||
<div v-if="activeTab === 'analytics' && event" class="mt-2">
|
||
<AnalyticsCard :event-id="eventId" />
|
||
</div>
|
||
|
||
<!-- Collaborators (Tier 2 Block C). -->
|
||
<div v-if="activeTab === 'collaborators' && event" class="mt-2">
|
||
<TeamCard :event-id="eventId" :your-role="event.your_role" />
|
||
</div>
|
||
|
||
<!-- Communications (Tier 2 Block F). Reminders + custom broadcasts. -->
|
||
<div v-if="activeTab === 'communications' && event" class="mt-2">
|
||
<CommunicationsCard :event-id="eventId" :your-role="event.your_role" />
|
||
</div>
|
||
|
||
<!-- Gate (Tier 2 Block G). The user-facing rebrand of the fraud
|
||
detector: strictness presets + trusted networks + decision
|
||
history, with the technical sliders/CIDR jargon tucked behind
|
||
disclosure. Editor+ for writes, viewer+ for reads. -->
|
||
<div v-if="activeTab === 'gate' && event" class="mt-2">
|
||
<GateCard :event-id="eventId" :your-role="event.your_role" />
|
||
</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>
|