feat: build core API, fraud engine, notifier, and frontend

Phase 1 — Core API (Go):
- Events, guests, tokens, RSVPs CRUD on PostgreSQL via pgx/v5
- HMAC-signed per-guest tokens with format validation
- Health endpoint with DB ping, slog JSON logging, graceful shutdown

Phase 2 — NATS + Fraud Engine:
- NATS JetStream pub/sub with explicit-ack consumers
- Python/FastAPI fraud engine with heuristic risk scoring
  (fingerprint mismatch, IP change, missing signals, repeated access)
- gRPC sync scoring with 250ms fail-open timeout
- Per-guest baseline tracking; risk bands low/medium/high/block

Phase 3 — Notifications + Frontend:
- Notification worker scaffolding (Twilio/SES stubs, retry/backoff)
- Nuxt 3 frontend with Tailwind dark theme + brand green
- Live monitor via WebSocket with auto-reconnect
- Activity history endpoint backfills monitor with RSVPs +
  scored access checks (including blocked attempts)

UX polish:
- Marketing-friendly landing page (hero mockup, how-it-works,
  features, use cases, testimonials, FAQ, final CTA)
- Animated layered card mockups on landing + new-event page
- Plus-ones stepper, RSVP status badges, filter buttons
- Friendly access-check labels (Verified/Review/Suspicious/Blocked)
- Dashboard hydration fix via ClientOnly wrapper

Infrastructure:
- docker-compose for full local dev (postgres, nats, api,
  fraud-engine, notifier, frontend)
- Multi-stage Dockerfiles, non-root UID 1000
- Integration tests with testcontainers-go

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-11 21:08:56 +01:00
parent f760fc3e21
commit 3f8bc58ca9
89 changed files with 22729 additions and 0 deletions
+445
View File
@@ -0,0 +1,445 @@
<script setup lang="ts">
interface Guest {
id: string
event_id: string
name: string
email?: string | null
phone?: string | null
plus_ones: number
created_at: string
rsvp_response?: 'attending' | 'declined' | 'maybe' | null
rsvp_plus_ones?: number | null
rsvp_risk_score?: number | null
rsvp_submitted_at?: string | null
has_token?: boolean
}
interface GuestStats {
total: number
attending: number
declined: number
maybe: number
pending: number
}
interface EventDetail {
id: string
host_id: string
name: string
slug: string
event_date: string
venue: string
max_capacity: number
status: string
created_at: string
updated_at: string
}
interface IssuedToken {
token: string
token_id: string
}
interface WSMessage {
type: string
event_id: string
payload: any
timestamp: string
}
const route = useRoute()
const eventId = route.params.id as string
const event = ref<EventDetail | null>(null)
const guests = ref<Guest[]>([])
const stats = ref<GuestStats>({ total: 0, attending: 0, declined: 0, maybe: 0, pending: 0 })
const filter = ref<'all' | 'attending' | 'declined' | 'maybe' | 'pending'>('all')
const loading = ref(true)
async function refresh() {
const [evt, list] = await Promise.all([
useApi<EventDetail>(`/events/${eventId}`),
useApi<{ guests: Guest[]; stats: GuestStats }>(`/events/${eventId}/guests`),
])
event.value = evt
guests.value = list.guests || []
if (list.stats) stats.value = list.stats
}
const filteredGuests = computed(() => {
if (filter.value === 'all') return guests.value
if (filter.value === 'pending') return guests.value.filter((g) => !g.rsvp_response)
return guests.value.filter((g) => g.rsvp_response === filter.value)
})
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
const newGuest = reactive({ name: '', email: '', phone: '', plus_ones: 0 })
const addingGuest = ref(false)
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,
},
})
Object.assign(newGuest, { name: '', email: '', phone: '', plus_ones: 0 })
await refresh()
} finally {
addingGuest.value = false
}
}
// Issue token
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
async function issueToken(guestId: string) {
issuing.value = guestId
try {
const res = await useApi<IssuedToken>(`/events/${eventId}/guests/${guestId}/tokens`, {
method: 'POST',
})
issued.value[guestId] = res
} catch (e) {
console.error(e)
} finally {
issuing.value = null
}
}
function rsvpUrl(token: string): string {
if (import.meta.server) return ''
return `${window.location.origin}/rsvp/${token}`
}
async function copyLink(guestId: string, token: string) {
if (!import.meta.client) return
try {
await navigator.clipboard.writeText(rsvpUrl(token))
copiedFor.value = guestId
if (copyResetTimer) clearTimeout(copyResetTimer)
copyResetTimer = setTimeout(() => {
if (copiedFor.value === guestId) copiedFor.value = null
}, 1500)
} catch (e) {
console.error('clipboard copy failed', e)
}
}
// Live monitor — RSVP + fraud feed via WS
interface FeedItem {
type: string
ts: string
text: string
band?: string
}
const feed = ref<FeedItem[]>([])
const wsConnected = ref(false)
const guestById = computed(() => Object.fromEntries(guests.value.map((g) => [g.id, g])))
function pushFeed(item: FeedItem) {
feed.value = [item, ...feed.value].slice(0, 50)
}
let stopWS: (() => void) | null = null
onMounted(() => {
stopWS = useEventWS(eventId, (msg: WSMessage) => {
wsConnected.value = true
if (msg.type === 'rsvp.confirmed') {
const g = guestById.value[msg.payload.guest_id]
pushFeed({
type: msg.type,
ts: msg.timestamp,
text: `${g?.name || 'Guest'}${msg.payload.response} (+${msg.payload.plus_ones || 0})`,
})
// Refresh stats and per-guest status so the counts reflect the new RSVP.
refresh().catch(() => {})
} else if (msg.type === 'fraud.scored') {
const g = guestById.value[msg.payload.guest_id]
const who = g?.name || 'A guest'
const band = (msg.payload.risk as string) || 'low'
// Plain-English text per band — no raw scores, no jargon.
const friendlyText: Record<string, string> = {
low: `${who} opened their invitation — looks normal.`,
medium: `${who}'s access looks a bit unusual — worth a glance.`,
high: `${who}'s access looks suspicious — review when you can.`,
block: `${who}'s access was blocked.`,
}
pushFeed({
type: msg.type,
ts: msg.timestamp,
text: friendlyText[band] || `${who}'s invitation was checked.`,
band,
})
}
})
})
onUnmounted(() => stopWS?.())
function fmtTime(iso: string): string {
try { return new Date(iso).toLocaleTimeString() } catch { return iso }
}
function fmtDate(iso: string): string {
try { return new Date(iso).toLocaleString() } catch { return iso }
}
// Friendly pill label for an access-check item (replaces "fraud · {band}").
function checkLabel(band?: string): string {
switch (band) {
case 'low': return 'Verified'
case 'medium': return 'Review'
case 'high': return 'Suspicious'
case 'block': return 'Blocked'
default: return 'Checked'
}
}
</script>
<template>
<section v-if="loading" class="text-sm text-zinc-500">Loading</section>
<section v-else-if="!event" class="card">Event not found.</section>
<section v-else class="space-y-8">
<div>
<NuxtLink to="/dashboard" class="mb-2 inline-block text-sm text-zinc-400 hover:text-zinc-200">
Back to dashboard
</NuxtLink>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">{{ event.name }}</h1>
<span class="badge bg-zinc-800 text-zinc-300">{{ event.status }}</span>
</div>
<p class="text-sm text-zinc-400">{{ event.venue }} · {{ fmtDate(event.event_date) }}</p>
</div>
<div class="grid gap-8 lg:grid-cols-[2fr_1fr]">
<!-- Guests + add form -->
<div class="space-y-6">
<div class="card">
<h2 class="mb-3 text-lg font-semibold">Add a guest</h2>
<form class="grid grid-cols-1 gap-3 md:grid-cols-2" @submit.prevent="addGuest">
<input v-model="newGuest.name" placeholder="Name" class="input md:col-span-2" required />
<input v-model="newGuest.email" type="email" placeholder="Email" class="input" />
<input v-model="newGuest.phone" placeholder="Phone" class="input" />
<!-- Plus-ones stepper -->
<div class="md:col-span-2">
<label class="label mb-1">Plus-ones allowed</label>
<div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
<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>
<button class="btn-primary md:col-span-2" :disabled="addingGuest">
{{ addingGuest ? 'Adding…' : 'Add guest' }}
</button>
</form>
</div>
<div class="card">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Guests</h2>
<button class="text-xs text-zinc-400 hover:text-zinc-200" @click="refresh">Refresh</button>
</div>
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-5">
<button
v-for="bucket in (['all','attending','declined','maybe','pending'] as const)"
:key="bucket"
class="rounded-md border px-3 py-2 text-left text-xs transition"
:class="filter === bucket
? 'border-brand-500 bg-brand-950/40 text-brand-200'
: 'border-zinc-800 bg-zinc-950 text-zinc-400 hover:border-zinc-700'"
@click="filter = bucket"
>
<div class="text-base font-semibold text-zinc-100">
{{ bucket === 'all' ? stats.total : stats[bucket] }}
</div>
<div class="capitalize">{{ bucket }}</div>
</button>
</div>
<div v-if="guests.length === 0" class="text-sm text-zinc-500">No guests yet.</div>
<div v-else-if="filteredGuests.length === 0" class="text-sm text-zinc-500">
No guests match this filter.
</div>
<ul v-else class="divide-y divide-zinc-800">
<li v-for="g in filteredGuests" :key="g.id" class="py-3 first:pt-0 last:pb-0">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-zinc-100">{{ g.name }}</span>
<span v-if="g.rsvp_response === 'attending'" class="badge-low">attending</span>
<span v-else-if="g.rsvp_response === 'declined'" class="badge bg-zinc-800 text-zinc-300">declined</span>
<span v-else-if="g.rsvp_response === 'maybe'" class="badge-medium">maybe</span>
<span v-else class="badge bg-zinc-800/60 text-zinc-500">no response</span>
<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>
</div>
<div class="mt-0.5 truncate text-xs text-zinc-500">
{{ g.email || '—' }}
<span v-if="g.rsvp_response">
· bringing
{{ g.rsvp_plus_ones ?? 0 }} of {{ g.plus_ones }} plus-ones
</span>
<span v-else>
· invited +{{ g.plus_ones }}
</span>
</div>
<div v-if="issued[g.id]" class="mt-2 break-all rounded border border-zinc-800 bg-zinc-950 px-2 py-1 font-mono text-xs text-brand-300">
{{ rsvpUrl(issued[g.id].token) }}
</div>
</div>
<div class="flex shrink-0 gap-2">
<button
v-if="!issued[g.id] && !g.rsvp_response"
class="btn-ghost"
:disabled="issuing === g.id || g.has_token"
:title="g.has_token ? 'A token has already been issued for this guest' : ''"
@click="issueToken(g.id)"
>
{{ issuing === g.id ? '…' : g.has_token ? 'Link issued' : 'Generate link' }}
</button>
<button
v-else-if="issued[g.id]"
class="btn-ghost"
:class="copiedFor === g.id ? 'text-brand-300' : ''"
@click="copyLink(g.id, issued[g.id].token)"
>
{{ copiedFor === g.id ? 'Copied ✓' : 'Copy link' }}
</button>
</div>
</div>
</li>
</ul>
</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>
</section>
</template>
+326
View File
@@ -0,0 +1,326 @@
<script setup lang="ts">
const { host } = useHost()
const name = ref('')
const slug = ref('')
const venue = ref('')
const eventDate = ref('')
const maxCapacity = ref<number>(50)
const submitting = ref(false)
const error = ref<string | null>(null)
async function submit() {
if (!host.value) return
error.value = null
submitting.value = true
try {
const created = await useApi<{ id: string }>('/events', {
method: 'POST',
body: {
host_id: host.value.id,
name: name.value,
slug: slug.value,
event_date: new Date(eventDate.value).toISOString(),
venue: venue.value,
max_capacity: maxCapacity.value,
},
})
await navigateTo(`/dashboard/events/${created.id}`)
} catch (e: any) {
error.value = e?.data?.error || e?.message || 'Failed to create event'
} finally {
submitting.value = false
}
}
function autoSlug() {
if (!slug.value) {
slug.value = name.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
}
// Pretty date for the live preview card
const previewDate = computed(() => {
if (!eventDate.value) return ''
try {
return new Date(eventDate.value).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
})
} catch {
return ''
}
})
// =============================================================
// Sample mockup data — clearly NOT the user's real activity
// =============================================================
interface DemoActivity {
id: number
name: string
initials: string
action: string
extra?: string | null
tone: 'attending' | 'declined' | 'maybe'
}
const demoPool: Omit<DemoActivity, 'id'>[] = [
{ name: 'John Doe', initials: 'JD', action: 'just confirmed', extra: '+2 guests', tone: 'attending' },
{ name: 'Jane Smith', initials: 'JS', action: 'is attending', extra: null, tone: 'attending' },
{ name: 'Alex Brown', initials: 'AB', action: 'replied maybe', extra: '+1 guest', tone: 'maybe' },
{ name: 'Maria Garcia', initials: 'MG', action: 'just confirmed', extra: '+3 guests', tone: 'attending' },
{ name: 'Tom Wilson', initials: 'TW', action: 'declined', extra: null, tone: 'declined' },
{ name: 'Emma Davis', initials: 'ED', action: 'is attending', extra: '+1 guest', tone: 'attending' },
]
let demoCounter = 0
function makeActivity(idx: number): DemoActivity {
return { ...demoPool[idx % demoPool.length], id: ++demoCounter }
}
// Three visible entries; newest pushed on top, oldest drops off the bottom.
const visibleActivities = ref<DemoActivity[]>([
makeActivity(0),
makeActivity(1),
makeActivity(2),
])
const sampleConfirmed = ref(18)
let actTimer: ReturnType<typeof setInterval> | null = null
let countTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
if (!import.meta.client) return
let idx = 3
actTimer = setInterval(() => {
visibleActivities.value = [makeActivity(idx), ...visibleActivities.value.slice(0, 2)]
idx++
}, 2800)
// Gentle tick-up on the sample stat so it feels alive
countTimer = setInterval(() => {
sampleConfirmed.value = sampleConfirmed.value >= 24 ? 18 : sampleConfirmed.value + 1
}, 4500)
})
onUnmounted(() => {
if (actTimer) clearInterval(actTimer)
if (countTimer) clearInterval(countTimer)
})
const toneClass: Record<DemoActivity['tone'], string> = {
attending: 'bg-brand-500/20 text-brand-300',
declined: 'bg-zinc-700/50 text-zinc-300',
maybe: 'bg-amber-500/20 text-amber-300',
}
// Capacity for the sample stat falls back to the form value when set,
// otherwise a friendly default — so the mockup feels connected to the form.
const sampleCapacity = computed(() => maxCapacity.value || 50)
</script>
<template>
<section>
<NuxtLink to="/dashboard" class="mb-6 inline-block text-sm text-zinc-400 hover:text-zinc-200">
Back to dashboard
</NuxtLink>
<div class="grid gap-12 lg:grid-cols-2 lg:items-start">
<!-- Left: form -->
<div>
<h1 class="mb-6 text-2xl font-semibold">Create a new event</h1>
<div v-if="!host" class="card text-sm text-zinc-400">
Please sign in first.
<NuxtLink to="/dashboard" class="text-brand-400">Go to dashboard</NuxtLink>
</div>
<form v-else class="card space-y-4" @submit.prevent="submit">
<div>
<label class="label">Event name</label>
<input v-model="name" class="input" placeholder="e.g. Sarah &amp; James Wedding" required @blur="autoSlug" />
</div>
<div>
<label class="label">
Slug
<span class="ml-1 font-normal normal-case text-zinc-500">(used in the URL)</span>
</label>
<input v-model="slug" class="input" required pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="sarah-james-wedding" />
</div>
<div>
<label class="label">Venue</label>
<input v-model="venue" class="input" placeholder="e.g. The Grand Ballroom" />
</div>
<div>
<label class="label">Date &amp; time</label>
<input v-model="eventDate" type="datetime-local" class="input" required />
</div>
<div>
<label class="label">
Max capacity
<span class="ml-1 font-normal normal-case text-zinc-500">(guests)</span>
</label>
<input v-model.number="maxCapacity" type="number" min="1" class="input" />
</div>
<button class="btn-primary w-full" :disabled="submitting">
{{ submitting ? 'Creating…' : 'Create event →' }}
</button>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
</form>
</div>
<!-- =================== RIGHT: SAMPLE PREVIEW =================== -->
<div class="hidden lg:block">
<!-- Clear preview header with sample disclaimer -->
<div class="mb-6 flex items-center justify-between gap-3">
<p class="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-brand-500">
<span class="h-px w-6 bg-brand-500"></span>
Live preview
</p>
<span
class="inline-flex items-center gap-1.5 rounded-full border border-amber-900/40 bg-amber-950/30 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wider text-amber-300"
title="Activity shown here is illustrative — your real dashboard will use actual guest responses"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<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>
Sample data
</span>
</div>
<!-- Stack of layered floating mockup cards -->
<div class="relative mx-auto w-full max-w-sm py-6">
<!-- Soft brand glow backdrop -->
<div class="pointer-events-none absolute -inset-10 bg-gradient-to-br from-brand-500/20 via-transparent to-brand-500/10 blur-3xl"></div>
<!-- Floating sparkles -->
<div class="pointer-events-none absolute -left-3 top-10 h-2 w-2 rounded-full bg-brand-400 opacity-70" style="animation: gg-ping 2.6s cubic-bezier(0,0,.2,1) infinite"></div>
<div class="pointer-events-none absolute right-4 -top-2 h-1.5 w-1.5 rounded-full bg-brand-300 opacity-70" style="animation: gg-ping 3.2s cubic-bezier(0,0,.2,1) infinite; animation-delay: .6s"></div>
<div class="pointer-events-none absolute -right-2 bottom-24 h-2 w-2 rounded-full bg-brand-500 opacity-60" style="animation: gg-ping 3.6s cubic-bezier(0,0,.2,1) infinite; animation-delay: 1.2s"></div>
<div class="pointer-events-none absolute left-6 -bottom-2 h-1.5 w-1.5 rounded-full bg-brand-400 opacity-60" style="animation: gg-ping 2.8s cubic-bezier(0,0,.2,1) infinite; animation-delay: 1.8s"></div>
<!-- 1. Sample stats card (top-right, sample badge attached) -->
<div
class="absolute -right-2 -top-6 z-20 rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-3 shadow-2xl backdrop-blur md:right-0"
style="animation: gg-float-cw 5.5s ease-in-out infinite"
>
<p class="mb-1.5 flex items-center justify-between gap-3 text-[10px] font-medium uppercase tracking-wider">
<span class="flex items-center gap-1.5 text-brand-400">
<span class="relative flex h-1.5 w-1.5">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-400 opacity-75"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand-400"></span>
</span>
RSVPs
</span>
<span class="text-[9px] text-zinc-600">sample</span>
</p>
<div class="flex items-baseline gap-2 text-zinc-100">
<span class="text-2xl font-bold tabular-nums transition-all duration-300">{{ sampleConfirmed }}</span>
<span class="text-xs text-zinc-500">of {{ sampleCapacity }} confirmed</span>
</div>
<div class="mt-2 h-1 w-full overflow-hidden rounded-full bg-zinc-800">
<div
class="h-full rounded-full bg-gradient-to-r from-brand-500 to-brand-400 transition-all duration-700 ease-out"
:style="{ width: `${Math.min(100, (sampleConfirmed / sampleCapacity) * 100)}%` }"
></div>
</div>
</div>
<!-- 2. The user's actual invitation preview (centre, "Your event" badge) -->
<div
class="relative z-10 overflow-hidden rounded-2xl border border-zinc-800 bg-gradient-to-br from-zinc-900 via-zinc-900 to-zinc-950 shadow-2xl"
style="animation: gg-float-ccw 6s ease-in-out infinite"
>
<div class="h-1 bg-gradient-to-r from-brand-600 via-brand-400 to-brand-600"></div>
<div class="p-6">
<div class="mb-3 flex items-center justify-between">
<p class="text-[10px] font-medium uppercase tracking-[0.22em] text-brand-400">
✦ You're Invited
</p>
<span class="rounded-full border border-brand-800/60 bg-brand-950/40 px-2 py-0.5 text-[9px] font-medium uppercase tracking-wider text-brand-400">
Your event
</span>
</div>
<h3 class="mb-1 truncate text-lg font-semibold text-zinc-100">
{{ name || 'Your event title' }}
</h3>
<p class="mb-4 text-xs text-zinc-500">
<span :class="venue ? 'text-zinc-400' : ''">{{ venue || 'Venue' }}</span>
·
<span :class="previewDate ? 'text-zinc-400' : ''">{{ previewDate || 'Date' }}</span>
</p>
<p class="mb-2 text-xs text-zinc-400">Will you be there?</p>
<div class="flex gap-1.5">
<span class="flex-1 rounded-md border border-brand-700/60 bg-brand-950/40 px-2 py-1.5 text-center text-xs font-medium text-brand-300">Attending</span>
<span class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-center text-xs text-zinc-500">Maybe</span>
<span class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-center text-xs text-zinc-500">Decline</span>
</div>
</div>
</div>
<!-- 3. Recent activity list (below the invitation, slight tilt + float) -->
<div
class="relative z-20 mx-1 mt-4 rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-3.5 shadow-2xl backdrop-blur"
style="animation: gg-float-cw-sm 5.4s ease-in-out infinite; animation-delay: .3s"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="flex items-center gap-1.5 text-xs font-medium text-zinc-300">
<span class="relative flex h-1.5 w-1.5">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-400 opacity-60"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand-400"></span>
</span>
Recent activity
</p>
<span class="rounded-full border border-amber-900/40 bg-amber-950/30 px-2 py-0.5 text-[9px] font-medium uppercase tracking-wider text-amber-300">
Sample
</span>
</div>
<TransitionGroup
tag="ul"
class="relative space-y-3"
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="-translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<li
v-for="item in visibleActivities"
:key="item.id"
class="flex items-start gap-2.5 text-xs"
>
<span
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold"
:class="toneClass[item.tone]"
>
{{ item.initials }}
</span>
<div class="min-w-0 flex-1">
<p class="text-zinc-300">
<span class="font-medium text-zinc-100">{{ item.name }}</span>
<span class="text-zinc-500"> {{ item.action }}</span>
<span v-if="item.extra" class="text-brand-400"> · {{ item.extra }}</span>
</p>
</div>
</li>
</TransitionGroup>
</div>
</div>
</div>
</div>
</section>
</template>