3f8bc58ca9
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>
118 lines
3.9 KiB
Vue
118 lines
3.9 KiB
Vue
<script setup lang="ts">
|
|
interface EventSummary {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
event_date: string
|
|
status: string
|
|
venue: string
|
|
max_capacity: number
|
|
}
|
|
|
|
interface EventsResponse {
|
|
events: EventSummary[]
|
|
}
|
|
|
|
const { host, bootstrap } = useHost()
|
|
|
|
const email = ref('')
|
|
const name = ref('')
|
|
const bootstrapping = ref(false)
|
|
const bootstrapError = ref<string | null>(null)
|
|
|
|
async function onBootstrap() {
|
|
bootstrapError.value = null
|
|
bootstrapping.value = true
|
|
try {
|
|
await bootstrap(email.value, name.value)
|
|
} catch (e: any) {
|
|
bootstrapError.value = e?.data?.error || e?.message || 'Failed to bootstrap'
|
|
} finally {
|
|
bootstrapping.value = false
|
|
}
|
|
}
|
|
|
|
const events = ref<EventSummary[]>([])
|
|
const loadingEvents = ref(false)
|
|
|
|
async function loadEvents() {
|
|
if (!host.value) return
|
|
loadingEvents.value = true
|
|
try {
|
|
const res = await useApi<EventsResponse>('/events', { query: { host_id: host.value.id } })
|
|
events.value = res.events
|
|
} finally {
|
|
loadingEvents.value = false
|
|
}
|
|
}
|
|
|
|
watch(host, loadEvents, { immediate: true })
|
|
|
|
function fmtDate(iso: string) {
|
|
try { return new Date(iso).toLocaleString() } catch { return iso }
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section>
|
|
<!--
|
|
The dashboard is auth-gated by a localStorage-backed host. Rendering
|
|
that conditional on the server (where there's no localStorage) and
|
|
then again on the client (where there is) causes a hydration
|
|
mismatch that leaves the layout stuck at the bootstrap card's width
|
|
after a hard refresh. Skipping SSR for this block fixes both the
|
|
flash and the layout shrink.
|
|
-->
|
|
<ClientOnly>
|
|
<div v-if="!host" class="card max-w-md">
|
|
<h1 class="mb-2 text-xl font-semibold">Get started</h1>
|
|
<p class="mb-4 text-sm text-zinc-400">
|
|
Demo bootstrap — enter an email + name to provision a host. We don't store passwords.
|
|
</p>
|
|
<label class="label">Email</label>
|
|
<input v-model="email" type="email" class="input mb-3" placeholder="you@example.com" />
|
|
<label class="label">Name</label>
|
|
<input v-model="name" type="text" class="input mb-4" placeholder="Your name" />
|
|
<button class="btn-primary w-full" :disabled="bootstrapping || !email || !name" @click="onBootstrap">
|
|
{{ bootstrapping ? 'Setting up…' : 'Continue' }}
|
|
</button>
|
|
<p v-if="bootstrapError" class="mt-3 text-sm text-red-400">{{ bootstrapError }}</p>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<div class="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-semibold">Your events</h1>
|
|
<p class="text-sm text-zinc-400">Signed in as {{ host.name }} ({{ host.email }})</p>
|
|
</div>
|
|
<NuxtLink to="/dashboard/events/new" class="btn-primary">New event</NuxtLink>
|
|
</div>
|
|
|
|
<div v-if="loadingEvents" class="text-sm text-zinc-500">Loading…</div>
|
|
<div v-else-if="events.length === 0" class="card text-sm text-zinc-400">
|
|
No events yet. Create one to get started.
|
|
</div>
|
|
<div v-else class="grid gap-4 md:grid-cols-2">
|
|
<NuxtLink
|
|
v-for="ev in events"
|
|
:key="ev.id"
|
|
:to="`/dashboard/events/${ev.id}`"
|
|
class="card transition hover:border-brand-700 hover:bg-zinc-900/80"
|
|
>
|
|
<div class="mb-1 flex items-center justify-between">
|
|
<h2 class="font-semibold text-zinc-50">{{ ev.name }}</h2>
|
|
<span class="text-xs uppercase tracking-wide text-zinc-500">{{ ev.status }}</span>
|
|
</div>
|
|
<p class="text-sm text-zinc-400">{{ ev.venue || '—' }}</p>
|
|
<p class="mt-2 text-xs text-zinc-500">{{ fmtDate(ev.event_date) }}</p>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<template #fallback>
|
|
<div class="text-sm text-zinc-500">Loading dashboard…</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</section>
|
|
</template>
|