98678ff5a3
Three threads of work land here together to close out Tier 2.
### Block H follow-ups — day-of check-in
- Scanner is now an "open on your phone" magic-link flow. Hosts on
desktop mint a scoped JWT via POST /events/{id}/scanner-ticket and
render its URL into a QR; phone scans it and lands on /scanner with
the ticket as bearer. The ticket carries Audience=scanner so it can
never substitute for a session token.
- Plus-one confirmation at the door: scan → POST /check-in/preview to
fetch guest + expected party size → confirm buttons ("Just them",
"Party of N", custom) → POST /check-in. No more silent arrival_count=1.
- Offline scan queue: failed POSTs go into an IndexedDB store and drain
on the 'online' event with poison-message protection.
- Day-of arrivals headline widget on the event overview, gated to the
host's local calendar date so it doesn't dominate the page weeks out.
- Tab nav restyled with inline heroicons + scrollable segmented control;
Check-in moves to the rightmost slot.
- PWA: manifest + service worker scoped to /scanner, generated 192/512
icons (Go scripted renderer in scripts/gen-scanner-icons.go).
- Confirmation email QR was rendering broken because html/template
rewrites data: URLs to #ZgotmplZ; mark the value as template.URL.
- Email "open your invitation" link 404'd because we had no token to
put after /rsvp/. Threaded AccessLink through the RSVPConfirmed NATS
event from the API at submit time.
### Block G remainder — geolocation + threshold preview
- Pluggable GeoResolver in the fraud engine (NullResolver, IPApiResolver
for the free ip-api.com fallback, MaxMindResolver behind GG_GEOIP_DB_PATH).
Wrapped in a Redis cache (30d TTL). Geo flows through both gRPC and
NATS scoring paths.
- geo_jump scoring feature: >500km in <1h flags ("accessed from Lagos
and Paris within 12 minutes"); >500km in <6h is a softer signal. The
existing single-signal cap keeps a lone geo_jump in MEDIUM.
- FraudScored event carries geo_country/city/lat/lon; ApplyScore uses
COALESCE so a later re-score without geo doesn't wipe earlier data.
- Threshold-slider live preview: GET /events/{id}/security/thresholds/preview
returns band counts the host's existing access events would have
fallen into under the proposed thresholds. Debounced (250ms) widget
under the Advanced sliders so the host gets concrete feedback instead
of guessing.
### Cross-cutting — audit, tier-gating, feature flags
- audit_log table + internal/audit.Recorder (async fire-and-forget on
detached context so an audit blip never fails the real action). Wired
into branding update, thresholds update, allowlist add/remove,
collaborator invite/role-change/remove, message create/send-now/cancel.
- Tier-gating: extended billing.Limits with MaxCollaborators,
CustomBranding, Scanner, Broadcasts. Free = none; Pro = 5 + all;
Business = unlimited. Gates the scanner-ticket, message create,
branding put, and collaborator invite endpoints with 402 +
structured upgrade payload. Auto-reminders, fraud detection, and
analytics deliberately stay on every tier — those are safety + visibility
features, not upsell levers.
- Feature flags: feature_flags table + internal/flags.Store with 30s
in-memory refresh, stable sha256(key + user_id) percent bucketing,
unknown-key-defaults-on. Six Tier 2 flags pre-seeded. Three handlers
(branding, broadcasts, scanner) check the kill switch ahead of the
tier gate so ops can pull a feature back without a redeploy.
### Verified
- go test ./... + fraud-engine pytest (12/12 incl. 3 new geo_jump tests + 5
new flags tests).
- docker compose build + up across api, fraud-engine, notifier, frontend.
- /health endpoints 200; migrations 0014 + 0015 applied; 6 flags
seeded; audit_log table + partial indexes confirmed.
- Fraud-engine logs confirm geo resolver kind=CachedGeoResolver provider=auto.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1843 lines
81 KiB
Vue
1843 lines
81 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 })
|
||
|
||
// Day-of arrivals headline. Visible above the tab bar once any check-in
|
||
// has been recorded; ticks up in real time via the check_in.recorded WS
|
||
// broadcast so the host doesn't have to flip to the Check-in tab to see
|
||
// progress on the door.
|
||
interface CheckInSummary {
|
||
arrived_headcount: number
|
||
expected_headcount: number
|
||
guests_checked_in: number
|
||
}
|
||
const checkInSummary = ref<CheckInSummary>({ arrived_headcount: 0, expected_headcount: 0, guests_checked_in: 0 })
|
||
const arrivalsPct = computed(() => {
|
||
const s = checkInSummary.value
|
||
if (!s.expected_headcount) return 0
|
||
return Math.min(100, Math.round((s.arrived_headcount / s.expected_headcount) * 100))
|
||
})
|
||
// The arrivals headline is a day-of tool, not a general-purpose
|
||
// counter. Surfacing it weeks before the event would be noise on the
|
||
// dashboard. Show it only when the host's local calendar date matches
|
||
// the event date (or there have actually been check-ins recorded
|
||
// today, which would imply the event is happening regardless of how
|
||
// event_date is stored).
|
||
const showArrivalsWidget = computed(() => {
|
||
if (!event.value?.event_date) return false
|
||
const evt = new Date(event.value.event_date)
|
||
if (isNaN(evt.getTime())) return false
|
||
const today = new Date()
|
||
return evt.getFullYear() === today.getFullYear()
|
||
&& evt.getMonth() === today.getMonth()
|
||
&& evt.getDate() === today.getDate()
|
||
})
|
||
async function refreshArrivals() {
|
||
try {
|
||
const data = await useApi<{ summary: CheckInSummary }>(`/events/${eventId}/check-ins`)
|
||
if (data?.summary) checkInSummary.value = data.summary
|
||
} catch {
|
||
// Viewer roles can read this; a transient failure shouldn't take
|
||
// down the rest of the page.
|
||
}
|
||
}
|
||
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' | 'checkin'
|
||
const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate', 'checkin']
|
||
// Tab metadata. Icons are inline heroicons-style outline marks at 24px
|
||
// viewBox; the template renders them at 18px and lets currentColor
|
||
// inherit the active/inactive text colour. Keeping the SVG data here
|
||
// (rather than in <template>) means the v-for stays one-liner clean.
|
||
interface TabDef {
|
||
id: EventTab
|
||
label: string
|
||
icon: string // inner SVG markup
|
||
}
|
||
const tabs: TabDef[] = [
|
||
{
|
||
id: 'guests',
|
||
label: 'Guests',
|
||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/>',
|
||
},
|
||
{
|
||
id: 'collaborators',
|
||
label: 'Collaborators',
|
||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/>',
|
||
},
|
||
{
|
||
id: 'communications',
|
||
label: 'Communications',
|
||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/>',
|
||
},
|
||
{
|
||
id: 'branding',
|
||
label: 'Branding',
|
||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42"/>',
|
||
},
|
||
{
|
||
id: 'analytics',
|
||
label: 'Analytics',
|
||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/>',
|
||
},
|
||
{
|
||
id: 'gate',
|
||
label: 'Gate',
|
||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z"/>',
|
||
},
|
||
{
|
||
id: 'checkin',
|
||
label: 'Check-in',
|
||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5C19.746 3.75 20.25 4.254 20.25 4.875v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z M6.75 6.75h.008v.008H6.75V6.75ZM6.75 16.5h.008v.008H6.75V16.5ZM16.5 6.75h.008v.008H16.5V6.75ZM13.5 13.5h.008v.008H13.5V13.5ZM13.5 19.5h.008v.008H13.5V19.5ZM19.5 13.5h.008v.008H19.5V13.5ZM19.5 19.5h.008v.008H19.5V19.5ZM16.5 16.5h.008v.008H16.5V16.5Z"/>',
|
||
},
|
||
]
|
||
|
||
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
|
||
// Fire-and-forget so a slow check-ins query doesn't hold up the rest
|
||
// of the page. Viewer roles will 403 on this endpoint — that's fine,
|
||
// refreshArrivals swallows it.
|
||
void refreshArrivals()
|
||
}
|
||
|
||
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 === 'check_in.recorded') {
|
||
// Day-of arrivals stream. The payload already carries the
|
||
// updated headcount so we don't have to re-fetch the summary.
|
||
const p = msg.payload || {}
|
||
if (typeof p.arrived_headcount === 'number') {
|
||
checkInSummary.value = {
|
||
arrived_headcount: p.arrived_headcount,
|
||
expected_headcount: p.expected_headcount ?? checkInSummary.value.expected_headcount,
|
||
guests_checked_in: p.guests_checked_in ?? checkInSummary.value.guests_checked_in,
|
||
}
|
||
}
|
||
pushFeed({
|
||
type: msg.type,
|
||
ts: msg.timestamp,
|
||
text: `${p.guest_name || 'Guest'} checked in${p.walk_in ? ' (walk-in)' : ''}.`,
|
||
})
|
||
} 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>
|
||
|
||
<!-- Day-of arrivals headline. Only renders once anything's been
|
||
recorded so it doesn't dominate the page in the weeks before
|
||
the event. Updates live via WS. -->
|
||
<div
|
||
v-if="showArrivalsWidget"
|
||
class="flex items-center justify-between gap-4 rounded-lg border border-brand-700/40 bg-brand-500/[0.06] px-4 py-3"
|
||
>
|
||
<div class="min-w-0">
|
||
<p class="text-[10px] font-medium uppercase tracking-widest text-brand-400">Arrived</p>
|
||
<p class="mt-0.5 text-2xl font-semibold tabular-nums text-brand-200">
|
||
{{ checkInSummary.arrived_headcount }}
|
||
<span class="text-sm font-normal text-zinc-400">of {{ checkInSummary.expected_headcount }}</span>
|
||
<span class="ml-2 text-xs font-normal text-zinc-500">·
|
||
{{ checkInSummary.guests_checked_in }}
|
||
{{ checkInSummary.guests_checked_in === 1 ? 'guest' : 'guests' }} in
|
||
</span>
|
||
</p>
|
||
</div>
|
||
<div class="flex shrink-0 items-center gap-3">
|
||
<div class="hidden h-1.5 w-32 overflow-hidden rounded-full bg-zinc-900 sm:block">
|
||
<div class="h-full rounded-full bg-brand-500 transition-all" :style="{ width: arrivalsPct + '%' }"></div>
|
||
</div>
|
||
<p class="text-lg font-semibold text-brand-200 tabular-nums">{{ arrivalsPct }}%</p>
|
||
<button
|
||
class="rounded-md border border-zinc-700 px-2.5 py-1 text-xs hover:border-zinc-500 hover:bg-zinc-900"
|
||
@click="setTab('checkin')"
|
||
>Open scanner</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab nav. Segmented-control take inspired by Linear / Vercel:
|
||
icon + label per tab, soft active fill, accessible focus ring,
|
||
consistent 18px outline-style icons (heroicons set). On narrow
|
||
viewports the row scrolls horizontally instead of wrapping to
|
||
two ragged rows — feels more "app-like" and keeps the labels
|
||
legible even on phones in portrait. -->
|
||
<nav
|
||
role="tablist"
|
||
aria-label="Event sections"
|
||
class="-mx-1 flex gap-1 overflow-x-auto rounded-xl border border-zinc-800 bg-zinc-900/60 p-1 shadow-inner [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||
>
|
||
<button
|
||
v-for="t in tabs"
|
||
:key="t.id"
|
||
role="tab"
|
||
:aria-selected="activeTab === t.id"
|
||
:class="[
|
||
'group relative inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3.5 py-2 text-sm font-medium transition-all duration-150',
|
||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950',
|
||
activeTab === t.id
|
||
? 'bg-brand-500/15 text-brand-100 shadow-[inset_0_-2px_0_0_#22c55e]'
|
||
: 'text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-100',
|
||
]"
|
||
@click="setTab(t.id)"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke-width="1.6"
|
||
stroke="currentColor"
|
||
class="h-[18px] w-[18px] shrink-0 transition-colors"
|
||
:class="activeTab === t.id ? 'text-brand-300' : 'text-zinc-500 group-hover:text-zinc-300'"
|
||
aria-hidden="true"
|
||
v-html="t.icon"
|
||
/>
|
||
<span class="leading-none">{{ t.label }}</span>
|
||
</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>
|
||
|
||
<!-- Check-in (Tier 2 Block H). Door scanner + live arrivals. -->
|
||
<div v-if="activeTab === 'checkin' && event" class="mt-2">
|
||
<CheckInCard :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>
|