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:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,32 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
NITRO_HOST=0.0.0.0 \
|
||||
NITRO_PORT=3000
|
||||
WORKDIR /app
|
||||
|
||||
# node:20-alpine ships a `node` user at uid/gid 1000 already.
|
||||
COPY --from=build --chown=node:node /app/.output ./.output
|
||||
|
||||
# Workaround for Nitro 2.12.4: the public-asset manifest declares paths
|
||||
# relative to .output/server/, but the runtime resolves them relative to
|
||||
# .output/server/chunks/nitro/, so it looks under chunks/public/. Symlink
|
||||
# that location to the real public dir so `node .output/server/index.mjs`
|
||||
# can actually find _nuxt/*.css and *.js.
|
||||
RUN ln -sfn ../../public /app/.output/server/chunks/public
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
const { host, clear } = useHost()
|
||||
const route = useRoute()
|
||||
|
||||
// GitHub icon is a "marketing" affordance — only show it on the public landing
|
||||
// page. Inside the app it just clutters the chrome.
|
||||
const showGithub = computed(() => route.path === '/')
|
||||
|
||||
function logout() {
|
||||
clear()
|
||||
navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-950 text-zinc-100 antialiased">
|
||||
<header class="border-b border-zinc-900">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span class="inline-block h-2.5 w-2.5 rounded-full bg-brand-500"></span>
|
||||
GuestGuard
|
||||
</NuxtLink>
|
||||
<nav class="flex items-center gap-4 text-sm text-zinc-400">
|
||||
<a
|
||||
v-if="showGithub"
|
||||
href="https://github.com/alchemistkay/guestguard"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="transition hover:text-zinc-100"
|
||||
title="View on GitHub"
|
||||
aria-label="View on GitHub"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<template v-if="host">
|
||||
<button class="transition hover:text-zinc-100" @click="logout">Sign out</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink to="/dashboard" class="transition hover:text-zinc-100">Sign in</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-6 py-8">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
|
||||
<footer class="mt-16 border-t border-zinc-900">
|
||||
<div class="mx-auto max-w-6xl px-6 py-6 text-xs text-zinc-500">
|
||||
© 2025 GuestGuard — Hassle-free RSVPs for every occasion.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: #09090b; /* zinc-950 */
|
||||
color: #fafafa; /* zinc-50 */
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-brand-500 text-zinc-950 hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-400;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn text-zinc-300 hover:bg-zinc-800;
|
||||
}
|
||||
.input {
|
||||
@apply block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm
|
||||
text-zinc-100 placeholder-zinc-500 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-sm;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-400;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
|
||||
}
|
||||
.badge-low { @apply badge bg-brand-900/40 text-brand-300; }
|
||||
.badge-medium { @apply badge bg-yellow-900/40 text-yellow-300; }
|
||||
.badge-high { @apply badge bg-orange-900/40 text-orange-300; }
|
||||
.badge-block { @apply badge bg-red-900/50 text-red-300; }
|
||||
}
|
||||
|
||||
/* Shared float animations for mockup cards (rotation baked in so the cards
|
||||
keep their tilt while gently bobbing). */
|
||||
@keyframes gg-float-cw {
|
||||
0%, 100% { transform: translateY(0) rotate(3deg); }
|
||||
50% { transform: translateY(-7px) rotate(3deg); }
|
||||
}
|
||||
@keyframes gg-float-cw-sm {
|
||||
0%, 100% { transform: translateY(0) rotate(2deg); }
|
||||
50% { transform: translateY(-5px) rotate(2deg); }
|
||||
}
|
||||
@keyframes gg-float-ccw {
|
||||
0%, 100% { transform: translateY(0) rotate(-2deg); }
|
||||
50% { transform: translateY(-9px) rotate(-2deg); }
|
||||
}
|
||||
@keyframes gg-float-ccw-sm {
|
||||
0%, 100% { transform: translateY(0) rotate(-3deg); }
|
||||
50% { transform: translateY(-6px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
@keyframes gg-ping {
|
||||
0%, 100% { transform: scale(1); opacity: 0.7; }
|
||||
50% { transform: scale(1.6); opacity: 0.3; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Typed wrapper around $fetch with the configured API base.
|
||||
// Usage: const events = await useApi<EventList>('/events')
|
||||
export async function useApi<T = unknown>(
|
||||
path: string,
|
||||
opts: { method?: string; body?: unknown; query?: Record<string, unknown> } = {},
|
||||
): Promise<T> {
|
||||
const config = useRuntimeConfig()
|
||||
const base = config.public.apiBase as string
|
||||
return await $fetch<T>(path, {
|
||||
baseURL: base,
|
||||
method: (opts.method ?? 'GET') as any,
|
||||
body: opts.body,
|
||||
query: opts.query,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Subscribes to /ws/events/:id and emits per-message callbacks.
|
||||
//
|
||||
// Auto-reconnects with exponential backoff up to 30s. Returns a cleanup
|
||||
// fn the caller invokes (e.g. inside onUnmounted).
|
||||
|
||||
interface WSMessage {
|
||||
type: string
|
||||
event_id: string
|
||||
payload: any
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export function useEventWS(eventId: string, onMessage: (msg: WSMessage) => void) {
|
||||
if (import.meta.server) return () => {}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const base = (config.public.wsBase as string) || ''
|
||||
const url = `${base}/ws/events/${eventId}`
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let attempt = 0
|
||||
let stopped = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function connect() {
|
||||
if (stopped) return
|
||||
ws = new WebSocket(url)
|
||||
|
||||
ws.onopen = () => {
|
||||
attempt = 0
|
||||
}
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data) as WSMessage
|
||||
onMessage(msg)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (stopped) return
|
||||
const backoff = Math.min(30_000, 500 * Math.pow(2, attempt++))
|
||||
reconnectTimer = setTimeout(connect, backoff)
|
||||
}
|
||||
ws.onerror = () => {
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return function stop() {
|
||||
stopped = true
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Lightweight browser fingerprint. Not a serious anti-fraud signal on its
|
||||
// own — the value is the *delta* between accesses, which is what the fraud
|
||||
// engine actually compares against.
|
||||
export function useFingerprint(): Record<string, string | number> {
|
||||
if (import.meta.server) return {}
|
||||
|
||||
const screen = window.screen
|
||||
return {
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
languages: (navigator.languages || []).join(','),
|
||||
user_agent: navigator.userAgent,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezone_offset: new Date().getTimezoneOffset(),
|
||||
screen_width: screen.width,
|
||||
screen_height: screen.height,
|
||||
color_depth: screen.colorDepth,
|
||||
pixel_ratio: window.devicePixelRatio,
|
||||
cookie_enabled: navigator.cookieEnabled ? 1 : 0,
|
||||
hardware_concurrency: navigator.hardwareConcurrency || 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Demo-grade host bootstrap. Real auth would replace this entirely; for now
|
||||
// we upsert by email and stash the host id in localStorage.
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'gg.host'
|
||||
|
||||
export function useHost() {
|
||||
const host = useState<User | null>('gg-host', () => null)
|
||||
|
||||
if (import.meta.client && !host.value) {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
host.value = JSON.parse(raw)
|
||||
} catch {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(email: string, name: string) {
|
||||
const u = await useApi<User>('/users', {
|
||||
method: 'POST',
|
||||
body: { email, name },
|
||||
})
|
||||
host.value = u
|
||||
if (import.meta.client) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(u))
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
function clear() {
|
||||
host.value = null
|
||||
if (import.meta.client) window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return { host, bootstrap, clear }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-09-01',
|
||||
devtools: { enabled: false },
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
app: {
|
||||
head: {
|
||||
title: 'GuestGuard',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ name: 'description', content: 'Real-time fraud detection for event RSVPs.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
// For local dev across origins. In prod this should be empty so all
|
||||
// requests go through same-origin via an ingress proxy.
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:8080',
|
||||
wsBase: process.env.NUXT_PUBLIC_WS_BASE || 'ws://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
Generated
+14496
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "guestguard-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev --host 0.0.0.0",
|
||||
"build": "nuxt build",
|
||||
"preview": "nuxt preview",
|
||||
"generate": "nuxt generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^3.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.12.1",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -0,0 +1,117 @@
|
||||
<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>
|
||||
@@ -0,0 +1,554 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({
|
||||
title: 'GuestGuard — Stress-free RSVPs for every occasion',
|
||||
description: 'Send personal invitations, track RSVPs in real time, and keep your guest list exactly as you planned it — weddings, parties, corporate events and more.',
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// Hero mockup: animated stat + cycling activity pill
|
||||
// =============================================================
|
||||
interface HeroActivity {
|
||||
id: number
|
||||
name: string
|
||||
initials: string
|
||||
action: string
|
||||
extra?: string | null
|
||||
tone: 'attending' | 'declined' | 'maybe'
|
||||
}
|
||||
|
||||
const heroPool: Omit<HeroActivity, 'id'>[] = [
|
||||
{ name: 'Aisha B.', initials: 'AB', action: 'just confirmed', extra: '+2 guests', tone: 'attending' },
|
||||
{ name: 'Marcus Chen', initials: 'MC', action: 'is attending', extra: null, tone: 'attending' },
|
||||
{ name: 'Sofia Rivera', initials: 'SR', action: 'replied maybe', extra: '+1 guest', tone: 'maybe' },
|
||||
{ name: 'John Doe', initials: 'JD', action: 'just confirmed', extra: '+3 guests', tone: 'attending' },
|
||||
{ name: 'Priya Sharma', initials: 'PS', action: 'is attending', extra: '+1 guest', tone: 'attending' },
|
||||
]
|
||||
|
||||
let heroCounter = 0
|
||||
const heroActivity = ref<HeroActivity>({ ...heroPool[0], id: ++heroCounter })
|
||||
const liveConfirmed = ref(42)
|
||||
|
||||
let heroActTimer: ReturnType<typeof setInterval> | null = null
|
||||
let heroCountTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
let idx = 1
|
||||
heroActTimer = setInterval(() => {
|
||||
heroActivity.value = { ...heroPool[idx % heroPool.length], id: ++heroCounter }
|
||||
idx++
|
||||
}, 3000)
|
||||
|
||||
// Slow ticking count-up — 42 → 47, then loops
|
||||
heroCountTimer = setInterval(() => {
|
||||
liveConfirmed.value = liveConfirmed.value >= 47 ? 42 : liveConfirmed.value + 1
|
||||
}, 4200)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (heroActTimer) clearInterval(heroActTimer)
|
||||
if (heroCountTimer) clearInterval(heroCountTimer)
|
||||
})
|
||||
|
||||
const heroToneClass: Record<HeroActivity['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',
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// FAQ items
|
||||
// =============================================================
|
||||
const faqs = [
|
||||
{
|
||||
q: 'Do my guests need to create an account to RSVP?',
|
||||
a: 'No. They simply click the personal link you send them and respond — no sign-up, no password, no friction. It works on any phone or computer.',
|
||||
},
|
||||
{
|
||||
q: 'Is GuestGuard free to start?',
|
||||
a: 'Yes. You can plan your first event, send invitations, and collect RSVPs without paying a cent. No credit card required to get going.',
|
||||
},
|
||||
{
|
||||
q: 'What happens if someone forwards their invitation to a friend?',
|
||||
a: "We notice. Each link is tied to the original guest, and our system quietly checks for unusual behaviour — like the link being used from a different device or location. You'll see the alert on your dashboard.",
|
||||
},
|
||||
{
|
||||
q: 'Can I import my guest list from a spreadsheet?',
|
||||
a: 'Absolutely. Drop in your CSV with names, emails, and phone numbers, and GuestGuard will handle the rest.',
|
||||
},
|
||||
{
|
||||
q: 'Will guests notice all the security checks happening behind the scenes?',
|
||||
a: 'Not at all. From their side it feels like a simple, beautiful RSVP page. The protection happens invisibly so the experience stays delightful.',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<!-- ============================================================ -->
|
||||
<!-- 1. HERO -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="grid gap-12 py-12 md:py-16 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:gap-16 lg:py-20">
|
||||
<div>
|
||||
<p class="mb-5 inline-flex items-center gap-2 rounded-full border border-brand-900/60 bg-brand-950/40 px-3 py-1 text-xs font-medium text-brand-400">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-brand-400"></span>
|
||||
For Weddings, Parties & Every Gathering in Between
|
||||
</p>
|
||||
<h1 class="mb-6 text-4xl font-semibold leading-tight tracking-tight text-zinc-50 md:text-5xl">
|
||||
Know Exactly Who's Coming<br />
|
||||
<span class="text-brand-500">Before the Big Day.</span>
|
||||
</h1>
|
||||
<p class="mb-8 max-w-xl text-lg leading-relaxed text-zinc-400">
|
||||
Send each guest a personal invitation link, watch RSVPs roll in on your live dashboard,
|
||||
and let GuestGuard quietly handle the rest — so you can focus on making your
|
||||
event unforgettable.
|
||||
</p>
|
||||
|
||||
<div class="mb-6 flex flex-wrap gap-3">
|
||||
<NuxtLink to="/dashboard" class="btn-primary px-6 py-2.5 text-base">
|
||||
Start Planning →
|
||||
</NuxtLink>
|
||||
<a href="#how-it-works" class="btn-ghost px-6 py-2.5 text-base">
|
||||
See How It Works
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Trust strip -->
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-zinc-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3.5 w-3.5 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Free to start
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3.5 w-3.5 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
No credit card needed
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3.5 w-3.5 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Set up in 2 minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =================== HERO VISUAL MOCKUP =================== -->
|
||||
<div class="relative mx-auto w-full max-w-sm py-6 lg:max-w-md">
|
||||
<!-- Soft brand glow backdrop -->
|
||||
<div class="pointer-events-none absolute -inset-10 bg-gradient-to-br from-brand-500/25 via-transparent to-brand-500/10 blur-3xl"></div>
|
||||
|
||||
<!-- Floating sparkle dots -->
|
||||
<div class="pointer-events-none absolute -left-4 top-8 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-6 -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-20 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-8 -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. Stats card (top-right, floating, tilts +3°) -->
|
||||
<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 gap-1.5 text-[10px] font-medium uppercase tracking-wider 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>
|
||||
Live RSVPs
|
||||
</p>
|
||||
<div class="flex items-baseline gap-2 text-zinc-100">
|
||||
<span class="text-2xl font-bold tabular-nums transition-all duration-300">{{ liveConfirmed }}</span>
|
||||
<span class="text-xs text-zinc-500">of 60 confirmed</span>
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
<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: `${(liveConfirmed / 60) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Main invitation card (centre, floating, tilts -2°) -->
|
||||
<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">
|
||||
<p class="mb-2 text-[10px] font-medium uppercase tracking-[0.22em] text-brand-400">
|
||||
✦ You're Invited
|
||||
</p>
|
||||
<h3 class="mb-1 text-xl font-semibold text-zinc-100">Sarah & James</h3>
|
||||
<p class="mb-4 text-xs text-zinc-500">The Grand Ballroom · Sat, Jun 15</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. Activity pill (bottom-left, floating, tilts -3°) -->
|
||||
<div
|
||||
class="absolute -bottom-6 -left-2 z-20 min-w-[14rem] rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-2.5 shadow-2xl backdrop-blur md:left-2"
|
||||
style="animation: gg-float-ccw-sm 5s ease-in-out infinite; animation-delay: .4s"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition duration-500 ease-out"
|
||||
enter-from-class="-translate-y-2 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-300 ease-in absolute inset-x-4 inset-y-2.5"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-2 opacity-0"
|
||||
mode="out-in"
|
||||
>
|
||||
<div :key="heroActivity.id" class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold"
|
||||
:class="heroToneClass[heroActivity.tone]"
|
||||
>
|
||||
{{ heroActivity.initials }}
|
||||
</span>
|
||||
<div class="min-w-0 text-xs">
|
||||
<p class="truncate font-medium text-zinc-100">
|
||||
{{ heroActivity.name }}
|
||||
<span class="font-normal text-zinc-500">{{ heroActivity.action }}</span>
|
||||
</p>
|
||||
<p class="text-[10px] text-zinc-500">
|
||||
<span v-if="heroActivity.extra" class="text-brand-400">{{ heroActivity.extra }} · </span>
|
||||
a moment ago
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 4. Security toast (bottom-right, floating, tilts +2°) -->
|
||||
<div
|
||||
class="absolute -bottom-2 -right-3 z-20 rounded-xl border border-amber-900/40 bg-zinc-900/95 px-3.5 py-2 shadow-2xl backdrop-blur md:right-0"
|
||||
style="animation: gg-float-cw-sm 5.8s ease-in-out infinite; animation-delay: 1s"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-amber-500/20 text-amber-300">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="text-xs">
|
||||
<p class="font-medium text-zinc-100">Forwarded link blocked</p>
|
||||
<p class="text-[10px] text-zinc-500">Suspicious activity flagged</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 2. HOW IT WORKS -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="how-it-works" class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">How It Works</p>
|
||||
<h2 class="mb-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
From Guest List to Confirmed Seats,<br />in Three Simple Steps.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="relative grid gap-6 md:grid-cols-3">
|
||||
<!-- Connector line (hidden on mobile) -->
|
||||
<div class="pointer-events-none absolute left-[16%] right-[16%] top-7 hidden h-px bg-gradient-to-r from-transparent via-zinc-800 to-transparent md:block"></div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<div class="relative z-10 mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-brand-900/60 bg-gradient-to-br from-brand-950 to-zinc-900 text-lg font-bold text-brand-400 shadow-lg">
|
||||
1
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Add Your Guest List</h3>
|
||||
<p class="mx-auto max-w-xs text-sm leading-relaxed text-zinc-400">
|
||||
Type names in, paste from a spreadsheet, or upload a CSV. We'll handle the rest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<div class="relative z-10 mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-brand-900/60 bg-gradient-to-br from-brand-950 to-zinc-900 text-lg font-bold text-brand-400 shadow-lg">
|
||||
2
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Send Personal Links</h3>
|
||||
<p class="mx-auto max-w-xs text-sm leading-relaxed text-zinc-400">
|
||||
Each guest gets their own private invitation — share it by WhatsApp, email,
|
||||
or however you like.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<div class="relative z-10 mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-brand-900/60 bg-gradient-to-br from-brand-950 to-zinc-900 text-lg font-bold text-brand-400 shadow-lg">
|
||||
3
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Watch RSVPs Roll In</h3>
|
||||
<p class="mx-auto max-w-xs text-sm leading-relaxed text-zinc-400">
|
||||
Confirmations appear on your dashboard as they happen. Final headcount,
|
||||
ready when you are.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 3. FEATURES -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">Why GuestGuard</p>
|
||||
<h2 class="text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
Everything You Need, Nothing You Don't.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="card group transition hover:border-brand-800/60">
|
||||
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand-500/10 transition group-hover:bg-brand-500/20">
|
||||
<svg class="h-5 w-5 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Personal Invitations</h3>
|
||||
<p class="text-sm leading-relaxed text-zinc-400">
|
||||
Every guest gets their own private link — no public sign-up forms, no
|
||||
gate-crashing, and no way for a forwarded link to let the wrong person in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card group transition hover:border-brand-800/60">
|
||||
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand-500/10 transition group-hover:bg-brand-500/20">
|
||||
<svg class="h-5 w-5 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Live RSVP Dashboard</h3>
|
||||
<p class="text-sm leading-relaxed text-zinc-400">
|
||||
Watch confirmations roll in the moment guests respond. See who's attending,
|
||||
who's declined, and who still hasn't replied — all filtered in one clean view.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card group transition hover:border-brand-800/60">
|
||||
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand-500/10 transition group-hover:bg-brand-500/20">
|
||||
<svg class="h-5 w-5 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Built-in Protection</h3>
|
||||
<p class="text-sm leading-relaxed text-zinc-400">
|
||||
Our system quietly watches for anything unusual — like the same link being
|
||||
used from two different places — and flags it before it becomes your problem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 4. PERFECT FOR -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">Perfect For</p>
|
||||
<h2 class="mb-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
Any Gathering Where Your Guest List Matters.
|
||||
</h2>
|
||||
<p class="mx-auto max-w-2xl text-zinc-500">
|
||||
Whether you're hosting six or six hundred, GuestGuard fits the way you plan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Weddings</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8a3 3 0 100-6 3 3 0 000 6zm0 0v4m-7 8h14M5 16a7 7 0 0114 0v4H5v-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Birthdays</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-3m-4 3v-7H9v7m4 0h-4m4 0h4M9 7h1m-1 4h1m4-4h1m-1 4h1" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Corporate</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Anniversaries</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Launches</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Private Dinners</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 5. TESTIMONIALS -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">Loved by Hosts</p>
|
||||
<h2 class="text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
A Little Peace of Mind Goes a Long Way.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<figure class="card">
|
||||
<div class="mb-4 flex gap-0.5 text-brand-400">
|
||||
<svg v-for="n in 5" :key="n" class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.367 2.446a1 1 0 00-.364 1.118l1.287 3.957c.3.922-.755 1.688-1.54 1.118L10 13.347l-3.367 2.446c-.784.57-1.838-.196-1.539-1.118l1.286-3.957a1 1 0 00-.364-1.118L2.65 9.154c-.784-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" />
|
||||
</svg>
|
||||
</div>
|
||||
<blockquote class="mb-5 text-sm leading-relaxed text-zinc-300">
|
||||
"Planning my daughter's wedding was already overwhelming — GuestGuard made the RSVP
|
||||
part the easiest piece. I knew exactly who was coming and didn't have to chase a single soul."
|
||||
</blockquote>
|
||||
<figcaption class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/20 text-sm font-semibold text-brand-300">EC</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-zinc-100">Emma Carter</p>
|
||||
<p class="text-xs text-zinc-500">Mother of the bride · London</p>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="card">
|
||||
<div class="mb-4 flex gap-0.5 text-brand-400">
|
||||
<svg v-for="n in 5" :key="n" class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.367 2.446a1 1 0 00-.364 1.118l1.287 3.957c.3.922-.755 1.688-1.54 1.118L10 13.347l-3.367 2.446c-.784.57-1.838-.196-1.539-1.118l1.286-3.957a1 1 0 00-.364-1.118L2.65 9.154c-.784-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" />
|
||||
</svg>
|
||||
</div>
|
||||
<blockquote class="mb-5 text-sm leading-relaxed text-zinc-300">
|
||||
"We host quarterly partner events. Cutting no-shows by sending personal links
|
||||
instead of a public form has been huge for our catering budget."
|
||||
</blockquote>
|
||||
<figcaption class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/20 text-sm font-semibold text-brand-300">MC</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-zinc-100">Marcus Chen</p>
|
||||
<p class="text-xs text-zinc-500">Corporate events lead · Singapore</p>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="card">
|
||||
<div class="mb-4 flex gap-0.5 text-brand-400">
|
||||
<svg v-for="n in 5" :key="n" class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.367 2.446a1 1 0 00-.364 1.118l1.287 3.957c.3.922-.755 1.688-1.54 1.118L10 13.347l-3.367 2.446c-.784.57-1.838-.196-1.539-1.118l1.286-3.957a1 1 0 00-.364-1.118L2.65 9.154c-.784-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" />
|
||||
</svg>
|
||||
</div>
|
||||
<blockquote class="mb-5 text-sm leading-relaxed text-zinc-300">
|
||||
"My birthday party went from a stressful headcount nightmare to something I was
|
||||
actually looking forward to. Everyone got a beautiful link, and that was it."
|
||||
</blockquote>
|
||||
<figcaption class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/20 text-sm font-semibold text-brand-300">PS</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-zinc-100">Priya Sharma</p>
|
||||
<p class="text-xs text-zinc-500">Birthday host · Toronto</p>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 6. FAQ -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">Questions, Answered</p>
|
||||
<h2 class="text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
The Things Hosts Usually Ask Us.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-3">
|
||||
<details
|
||||
v-for="(item, i) in faqs"
|
||||
:key="i"
|
||||
class="group rounded-xl border border-zinc-800 bg-zinc-900/40 px-5 py-4 transition open:border-brand-900/40 open:bg-zinc-900"
|
||||
>
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-4">
|
||||
<span class="font-medium text-zinc-100">{{ item.q }}</span>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0 text-zinc-500 transition group-open:rotate-45 group-open:text-brand-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</summary>
|
||||
<p class="mt-3 text-sm leading-relaxed text-zinc-400">{{ item.a }}</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 7. FINAL CTA -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="relative my-16 overflow-hidden rounded-3xl border border-brand-900/40 bg-gradient-to-br from-brand-950/40 via-zinc-900 to-zinc-950 p-10 text-center md:p-16">
|
||||
<div class="pointer-events-none absolute -left-20 -top-20 h-72 w-72 rounded-full bg-brand-500/10 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-20 -right-20 h-72 w-72 rounded-full bg-brand-500/10 blur-3xl"></div>
|
||||
|
||||
<div class="relative">
|
||||
<h2 class="mb-4 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
Ready to Host With Confidence?
|
||||
</h2>
|
||||
<p class="mx-auto mb-8 max-w-xl text-zinc-400">
|
||||
Your next event deserves a perfect guest list. Get started in two minutes —
|
||||
no credit card, no commitment.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<NuxtLink to="/dashboard" class="btn-primary px-6 py-3 text-base">
|
||||
Start Planning Your Event →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p class="mt-6 text-xs text-zinc-500">
|
||||
Already have an account?
|
||||
<NuxtLink to="/dashboard" class="text-brand-400 hover:text-brand-300">Sign in here</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
interface AccessResponse {
|
||||
guest: { id: string; name: string; email?: string | null; plus_ones: number }
|
||||
event: { id: string; name: string; venue: string; event_date: string }
|
||||
token: { id: string; status: string; expires_at: string }
|
||||
access_log_id: string
|
||||
}
|
||||
|
||||
interface RSVPSubmitResponse {
|
||||
rsvp?: { id: string; response: string; plus_ones: number; risk_score: number }
|
||||
fraud: { score: number; risk: string; reasons: string[]; used: boolean }
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token as string
|
||||
|
||||
const loading = ref(true)
|
||||
const access = ref<AccessResponse | null>(null)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const response = ref<'attending' | 'declined' | 'maybe'>('attending')
|
||||
const plusOnes = ref(0)
|
||||
const dietary = ref('')
|
||||
|
||||
const submitting = ref(false)
|
||||
const result = ref<RSVPSubmitResponse | null>(null)
|
||||
const submitError = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
access.value = await useApi<AccessResponse>(`/access/${token}`)
|
||||
if (access.value) plusOnes.value = access.value.guest.plus_ones || 0
|
||||
} catch (e: any) {
|
||||
loadError.value = e?.data?.error || e?.message || 'Invitation not found'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
submitError.value = null
|
||||
try {
|
||||
const fp = useFingerprint()
|
||||
result.value = await useApi<RSVPSubmitResponse>(`/rsvp/${token}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
response: response.value,
|
||||
plus_ones: plusOnes.value,
|
||||
dietary_notes: dietary.value || null,
|
||||
fingerprint: fp,
|
||||
},
|
||||
})
|
||||
} catch (e: any) {
|
||||
// 403 from BLOCK band returns a JSON body; surface its decision too.
|
||||
if (e?.data?.fraud) {
|
||||
result.value = e.data
|
||||
} else {
|
||||
submitError.value = e?.data?.error || e?.message || 'Could not submit RSVP'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(iso?: string) {
|
||||
if (!iso) return ''
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto max-w-xl py-8">
|
||||
<div v-if="loading" class="text-sm text-zinc-500">Looking up your invitation…</div>
|
||||
|
||||
<div v-else-if="loadError" class="card border-red-900/60 bg-red-950/30">
|
||||
<h1 class="mb-2 text-xl font-semibold text-red-200">Invitation unavailable</h1>
|
||||
<p class="text-sm text-red-300">{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="result?.blocked" class="card border-red-900/60 bg-red-950/30">
|
||||
<h1 class="mb-2 text-xl font-semibold text-red-200">This invitation cannot be used</h1>
|
||||
<p class="text-sm text-red-300">
|
||||
The host has been notified of a suspicious access attempt.
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-red-400">
|
||||
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="result?.rsvp" class="card border-brand-900/60 bg-brand-950/20">
|
||||
<h1 class="mb-2 text-xl font-semibold text-brand-200">You're confirmed</h1>
|
||||
<p class="text-sm text-brand-300">
|
||||
Response recorded as <strong>{{ result.rsvp.response }}</strong> with
|
||||
+{{ result.rsvp.plus_ones }} plus-ones.
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-zinc-500">
|
||||
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
|
||||
<span v-if="!result.fraud.used"> · fallback</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="access" class="card">
|
||||
<p class="text-xs uppercase tracking-widest text-brand-500">Invitation</p>
|
||||
<h1 class="mb-1 text-2xl font-semibold">{{ access.event.name }}</h1>
|
||||
<p class="mb-6 text-sm text-zinc-400">
|
||||
{{ access.event.venue }} · {{ fmtDate(access.event.event_date) }}
|
||||
</p>
|
||||
|
||||
<p class="mb-6 text-sm">
|
||||
Hi <span class="font-medium text-zinc-100">{{ access.guest.name }}</span> —
|
||||
please confirm your response below.
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="label">Response</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in (['attending', 'declined', 'maybe'] as const)"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="btn-ghost flex-1 capitalize"
|
||||
:class="response === opt ? 'border border-brand-500 text-brand-300' : 'border border-zinc-800'"
|
||||
@click="response = opt"
|
||||
>{{ opt }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="access.guest.plus_ones > 0" class="mb-4">
|
||||
<label class="label">
|
||||
Plus-ones
|
||||
<span class="ml-1 font-normal normal-case text-zinc-500">
|
||||
(you may bring up to {{ access.guest.plus_ones }})
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-12 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
|
||||
:disabled="plusOnes <= 0"
|
||||
@click="plusOnes = Math.max(0, plusOnes - 1)"
|
||||
>−</button>
|
||||
<span class="flex-1 text-center text-base font-semibold tabular-nums text-zinc-100">{{ plusOnes }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-12 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
|
||||
:disabled="plusOnes >= access.guest.plus_ones"
|
||||
@click="plusOnes = Math.min(access.guest.plus_ones, plusOnes + 1)"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="mb-4 text-xs text-zinc-500">
|
||||
This invitation is for one person only — no plus-ones for this one.
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="label">Dietary notes (optional)</label>
|
||||
<input v-model="dietary" class="input" placeholder="e.g. vegetarian" />
|
||||
</div>
|
||||
|
||||
<button class="btn-primary w-full" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? 'Submitting…' : 'Submit RSVP' }}
|
||||
</button>
|
||||
|
||||
<p v-if="submitError" class="mt-3 text-sm text-red-400">{{ submitError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
DEFAULT: '#22c55e',
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'ui-sans-serif',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user