003a320690
QR codes on RSVP confirmations, a phone-friendly door scanner, walk-in
support, and a live arrivals widget that updates over WebSocket. Closes
the final Tier 2 block.
Schema (migration 0013)
- check_ins (id, guest_id UNIQUE, checked_in_at, checked_in_by,
arrival_count, notes, walk_in). UNIQUE on guest_id is the
double-check-in guard at the DB layer; signature validation lives
in the QR JWT.
QR JWT
- internal/auth/checkin_qr.go: CheckInQRSigner mints {event_id,
guest_id, exp} payloads with the platform's existing HMAC secret.
Issue() extends expiry to eventDate+24h so a QR minted weeks in
advance still scans on the day. Parse() distinguishes
ErrExpiredJWT from generic ErrInvalidJWT so the API can render a
friendlier 410.
- Unit tests cover round-trip, wrong-secret rejection, expiry
detection, and short-secret refusal at construction time.
Domain + storage
- domain.CheckIn + CheckInSummary
- storage.CheckInRepo: Record (returns ErrAlreadyCheckedIn on the
unique violation), ListByEvent, Summary (arrived headcount,
expected headcount, guests-checked-in count), GuestBelongsToEvent
(belt-and-braces guard against a forged JWT pointing at a
different event's guest).
API
- GET /access/{token} now embeds a check_in payload (raw JWT + a
base64-encoded PNG via skip2/go-qrcode) for attending RSVPs, so
the confirmation page can render the code straight into an <img>.
- POST /events/{id}/check-in — editor+. Validates the QR JWT,
refuses cross-event payloads (400), refuses expired ones (410),
records the row, broadcasts check_in.recorded over the existing
WS hub so the live dashboard updates.
- POST /events/{id}/walk-ins — editor+. Creates the guest + check-in
in one logical op for a door-add who wasn't on the original list.
- GET /events/{id}/check-ins — viewer+. Returns the list and the
summary together so the dashboard widget hydrates in one call.
Frontend
- New CheckInCard.vue: live arrivals widget ("47 of 60 · 78%" plus
a progress bar), recent-arrivals list, Walk-in button, and a
"Start scanning" button that opens a full-screen camera modal.
jsQR loaded from CDN on first open (no bundler dep). Scan
throttling + dedupe prevents the 30fps camera loop from POSTing
N times per paper QR. Successful scan vibrates the phone.
Duplicate (409) → "Already checked in" toast; expired (410) →
"This code has expired"; foreign-event (400) → "doesn't look
like one of your guests".
- New "Check-in" tab on the event-detail page, between
Communications and Branding.
- RSVP confirmation card + revisit card both surface a "Save for
the day" / "Your door code" QR block for attending guests. The
PNG ships pre-rendered from the API so the frontend doesn't need
its own QR library.
- The submit flow now refetches /access after a successful POST so
the QR appears immediately on first submit, not just on revisit.
Tests
- Backend unit tests for the QR signer (round-trip, wrong-secret,
expired, short-secret rejection).
- Integration: TestCheckInHappyPath (scan -> 200, double-scan ->
409, summary reflects arrival), TestCheckInRejectsForeignQR
(event A's JWT can't be used on event B), TestWalkInCreatesGuest
AndCheckIn (door-add creates both rows).
- Full integration suite passes (188.3s, 41 tests / 80+ subtests).
Tier 2 is complete: Blocks A through H all shipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
526 lines
20 KiB
Vue
526 lines
20 KiB
Vue
<script setup lang="ts">
|
||
interface ExistingRSVP {
|
||
id: string
|
||
response: 'attending' | 'declined' | 'maybe'
|
||
plus_ones: number
|
||
dietary_notes?: string | null
|
||
submitted_at: string
|
||
edit_count: number
|
||
}
|
||
|
||
interface CalendarLinks {
|
||
google: string
|
||
outlook: string
|
||
yahoo: string
|
||
ics: string
|
||
}
|
||
|
||
interface BrandingPayload {
|
||
primary_color?: string | null
|
||
accent_color?: string | null
|
||
logo_url?: string | null
|
||
cover_image_url?: string | null
|
||
font_family?: string | null
|
||
greeting_message?: string | null
|
||
}
|
||
|
||
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
|
||
rsvp?: ExistingRSVP | null
|
||
// Forwarded-link defence (Block G follow-up). When the Gate didn't
|
||
// recognise the device, the existing RSVP is hidden from the
|
||
// response and these flags drive the alternative UX.
|
||
rsvp_submitted_elsewhere?: boolean
|
||
can_request_edit_link?: boolean
|
||
calendar?: CalendarLinks
|
||
branding?: BrandingPayload | null
|
||
// Block H — per-guest QR code, only populated for attending RSVPs.
|
||
// qr_image is a data: URL ready for an <img src>.
|
||
check_in?: { qr: string; qr_image: string } | null
|
||
}
|
||
|
||
interface RSVPSubmitResponse {
|
||
rsvp?: ExistingRSVP & { risk_score?: number }
|
||
fraud: { score: number; risk: string; reasons: string[]; used: boolean }
|
||
blocked: boolean
|
||
edited?: boolean
|
||
}
|
||
|
||
const MAX_EDITS = 5
|
||
|
||
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)
|
||
|
||
// existing tracks the RSVP that was on file when the page loaded.
|
||
// editing toggles the form open over the "already responded" summary.
|
||
const existing = ref<ExistingRSVP | null>(null)
|
||
const editing = ref(false)
|
||
|
||
// Forwarded-link defence state.
|
||
// When the access response sets rsvp_submitted_elsewhere=true, an RSVP
|
||
// exists but the Gate didn't recognise this device, so we don't surface
|
||
// the response details. The original guest (if it really is them on a
|
||
// new phone) can request a one-time edit link to their email.
|
||
const respondedElsewhere = ref(false)
|
||
const canRequestEditLink = ref(false)
|
||
const requestingEditLink = ref(false)
|
||
const editLinkSent = ref(false)
|
||
const editLinkError = ref<string | null>(null)
|
||
|
||
// editNonce comes from the magic edit link the guest receives by email
|
||
// (?edit=<nonce> in the URL). When present we pass it on /access and
|
||
// later on PATCH so the backend treats this device as the original.
|
||
const editNonce = computed(() => {
|
||
const q = route.query.edit
|
||
return typeof q === 'string' ? q : ''
|
||
})
|
||
|
||
const editsRemaining = computed(() => {
|
||
const used = existing.value?.edit_count ?? 0
|
||
return Math.max(0, MAX_EDITS - used)
|
||
})
|
||
const editLimitReached = computed(() => editsRemaining.value <= 0)
|
||
|
||
function prefillFromRSVP(rsvp: ExistingRSVP) {
|
||
response.value = rsvp.response
|
||
plusOnes.value = rsvp.plus_ones
|
||
dietary.value = rsvp.dietary_notes ?? ''
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
// Pass through the edit nonce if the guest arrived via the magic
|
||
// edit link. The backend uses it to unhide the existing RSVP.
|
||
const path = editNonce.value
|
||
? `/access/${token}?edit=${encodeURIComponent(editNonce.value)}`
|
||
: `/access/${token}`
|
||
access.value = await useApi<AccessResponse>(path)
|
||
if (!access.value) return
|
||
plusOnes.value = access.value.guest.plus_ones || 0
|
||
if (access.value.rsvp) {
|
||
existing.value = access.value.rsvp
|
||
prefillFromRSVP(access.value.rsvp)
|
||
// Magic-link arrival: open the form straight into edit mode so
|
||
// the guest doesn't have to click "Change my response" first.
|
||
if (editNonce.value) editing.value = true
|
||
} else if (access.value.rsvp_submitted_elsewhere) {
|
||
respondedElsewhere.value = true
|
||
canRequestEditLink.value = !!access.value.can_request_edit_link
|
||
}
|
||
} catch (e: any) {
|
||
loadError.value = e?.data?.error || e?.message || 'Invitation not found'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
async function requestEditLink() {
|
||
requestingEditLink.value = true
|
||
editLinkError.value = null
|
||
try {
|
||
await useApi(`/access/${token}/request-edit-link`, { method: 'POST' })
|
||
editLinkSent.value = true
|
||
} catch (e: any) {
|
||
editLinkError.value = e?.data?.error || e?.message || 'Could not send the edit link'
|
||
} finally {
|
||
requestingEditLink.value = false
|
||
}
|
||
}
|
||
|
||
async function submit() {
|
||
submitting.value = true
|
||
submitError.value = null
|
||
const isEdit = !!existing.value
|
||
try {
|
||
const fp = useFingerprint()
|
||
result.value = await useApi<RSVPSubmitResponse>(`/rsvp/${token}`, {
|
||
method: isEdit ? 'PATCH' : 'POST',
|
||
body: {
|
||
response: response.value,
|
||
plus_ones: plusOnes.value,
|
||
dietary_notes: dietary.value || null,
|
||
fingerprint: fp,
|
||
// Forwarded-link defence: pass the edit nonce on PATCH so the
|
||
// backend can bypass the same-device check when the guest is
|
||
// legitimately on a new device. Empty on POST and on
|
||
// same-device edits.
|
||
...(editNonce.value ? { edit_nonce: editNonce.value } : {}),
|
||
},
|
||
})
|
||
// Refresh the cached "existing" view so a back-to-summary toggle shows
|
||
// the new state and the edit counter without a page reload.
|
||
if (result.value?.rsvp) {
|
||
existing.value = {
|
||
id: result.value.rsvp.id,
|
||
response: result.value.rsvp.response,
|
||
plus_ones: result.value.rsvp.plus_ones,
|
||
dietary_notes: result.value.rsvp.dietary_notes ?? null,
|
||
submitted_at: result.value.rsvp.submitted_at,
|
||
edit_count: result.value.rsvp.edit_count,
|
||
}
|
||
}
|
||
editing.value = false
|
||
|
||
// Re-fetch access so the response carries the Block H check-in QR
|
||
// (the submit endpoint doesn't render the QR — only access does).
|
||
// Best-effort: a failure here doesn't roll back the successful
|
||
// RSVP, the guest just won't see the QR until they refresh.
|
||
try {
|
||
const path = editNonce.value
|
||
? `/access/${token}?edit=${encodeURIComponent(editNonce.value)}`
|
||
: `/access/${token}`
|
||
const fresh = await useApi<AccessResponse>(path)
|
||
access.value = fresh
|
||
} catch { /* non-fatal */ }
|
||
} catch (e: any) {
|
||
// BLOCK band returns 403 with the fraud decision; surface it the same
|
||
// way the first-submit path does.
|
||
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 startEditing() {
|
||
if (existing.value) prefillFromRSVP(existing.value)
|
||
result.value = null
|
||
editing.value = true
|
||
}
|
||
|
||
function cancelEditing() {
|
||
if (existing.value) prefillFromRSVP(existing.value)
|
||
editing.value = false
|
||
}
|
||
|
||
function fmtDate(iso?: string) {
|
||
if (!iso) return ''
|
||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||
}
|
||
|
||
// Calendar links come from the backend so we don't reimplement Google /
|
||
// Outlook / Yahoo encoding rules per provider. They're available as soon
|
||
// as /access loads — the guest can grab them before submitting.
|
||
const calendar = computed<CalendarLinks | null>(() => access.value?.calendar ?? null)
|
||
|
||
// Branding (Tier 2 Block D). Null = use defaults. Each field is optional;
|
||
// missing pieces fall back to the GuestGuard look.
|
||
const branding = computed<BrandingPayload | null>(() => access.value?.branding ?? null)
|
||
const brandingStyle = computed(() => {
|
||
const b = branding.value
|
||
if (!b) return {}
|
||
const style: Record<string, string> = {}
|
||
if (b.primary_color) style['--brand-primary'] = b.primary_color
|
||
if (b.accent_color) style['--brand-accent'] = b.accent_color
|
||
if (b.font_family) style.fontFamily = b.font_family
|
||
return style
|
||
})
|
||
const greetingMessage = computed(() => branding.value?.greeting_message || '')
|
||
|
||
// showForm = no prior submission, or the guest has clicked "Change my response"
|
||
const showForm = computed(() => editing.value || !existing.value)
|
||
const submitLabel = computed(() => {
|
||
if (submitting.value) return existing.value ? 'Updating…' : 'Submitting…'
|
||
return existing.value ? 'Update RSVP' : 'Submit RSVP'
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- Branding (Tier 2 Block D) lives at the section root so every card
|
||
below inherits the CSS vars + font. brandingStyle is empty when the
|
||
host hasn't customised, so defaults apply naturally. -->
|
||
<section class="mx-auto max-w-xl py-8" :style="brandingStyle">
|
||
<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">
|
||
Something about this attempt looked off and the host has been notified.
|
||
If you believe this is a mistake, please reach out to them directly.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Forwarded-link landing. There's an RSVP on file for this invitation,
|
||
but we don't recognise this device, so we don't show the response
|
||
or let it be changed here. If we have the original guest's email
|
||
on file, they can request a one-time edit link to recover. -->
|
||
<div v-else-if="respondedElsewhere" class="card">
|
||
<h1 class="mb-2 text-xl font-semibold">This invitation has already been used</h1>
|
||
<p class="mb-4 text-sm text-zinc-300">
|
||
Someone has already replied with this invitation, so the response
|
||
is private. If you forwarded the link, please ask the original guest
|
||
to reach out to the host.
|
||
</p>
|
||
|
||
<div v-if="canRequestEditLink && !editLinkSent" class="rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-4">
|
||
<p class="mb-3 text-sm text-zinc-200">
|
||
<strong>Is this your invitation, just on a new device?</strong>
|
||
We can send a one-time edit link to the email we have on file
|
||
so you can review and update your reply.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
class="btn-primary text-sm"
|
||
:disabled="requestingEditLink"
|
||
@click="requestEditLink"
|
||
>
|
||
{{ requestingEditLink ? 'Sending…' : 'Send me an edit link' }}
|
||
</button>
|
||
<p v-if="editLinkError" class="mt-3 text-sm text-red-400">{{ editLinkError }}</p>
|
||
<p class="mt-3 text-xs text-zinc-500">
|
||
The link expires in 30 minutes. If you don't see the email shortly,
|
||
check your spam folder or ask your host to resend your invitation.
|
||
</p>
|
||
</div>
|
||
|
||
<div v-else-if="editLinkSent" class="rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-4">
|
||
<p class="text-sm text-brand-200">
|
||
<strong>Edit link on its way.</strong>
|
||
</p>
|
||
<p class="mt-2 text-sm text-zinc-300">
|
||
Check your inbox for an email from us. The link expires in 30 minutes,
|
||
so do click it as soon as you can.
|
||
</p>
|
||
</div>
|
||
|
||
<p v-else class="text-xs text-zinc-500">
|
||
If you think you're the original guest, please contact your host for help.
|
||
</p>
|
||
</div>
|
||
|
||
<div v-else-if="result?.rsvp && !editing" class="card border-brand-900/60 bg-brand-950/20">
|
||
<h1 class="mb-2 text-xl font-semibold text-brand-200">
|
||
{{ result.edited ? 'Your response has been updated' : "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>
|
||
|
||
<AddToCalendar
|
||
v-if="calendar && result.rsvp.response === 'attending'"
|
||
:links="calendar"
|
||
:token="token"
|
||
class="mt-5 border-t border-zinc-800 pt-4"
|
||
/>
|
||
|
||
<!-- Block H — door QR for attending guests. Shown right under
|
||
"you're confirmed" because that's when the guest is most
|
||
likely to screenshot or save the email. -->
|
||
<div
|
||
v-if="access?.check_in && result.rsvp.response === 'attending'"
|
||
class="mt-5 border-t border-zinc-800 pt-4"
|
||
>
|
||
<p class="mb-2 text-xs font-medium uppercase tracking-widest text-brand-500">
|
||
Save for the day
|
||
</p>
|
||
<p class="mb-3 text-sm text-zinc-300">
|
||
Show this code at the door for a quick check-in. Screenshot it now,
|
||
or look out for the same code on your confirmation email.
|
||
</p>
|
||
<div class="flex items-center justify-center rounded-md border border-zinc-800 bg-white p-3">
|
||
<img
|
||
:src="access.check_in.qr_image"
|
||
alt="Your check-in QR code"
|
||
class="h-44 w-44"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!editLimitReached" class="mt-5 flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||
<p class="text-xs text-zinc-500">
|
||
Need to change something? You have {{ editsRemaining }}
|
||
{{ editsRemaining === 1 ? 'edit' : 'edits' }} left.
|
||
</p>
|
||
<button type="button" class="btn-ghost text-sm" @click="startEditing">Change my response</button>
|
||
</div>
|
||
<p v-else class="mt-5 border-t border-zinc-800 pt-4 text-xs text-zinc-500">
|
||
You've used all {{ MAX_EDITS }} edits on this invitation — contact your host if you need
|
||
to change anything else.
|
||
</p>
|
||
</div>
|
||
|
||
<div v-else-if="existing && !editing" class="card border-brand-900/60 bg-brand-950/20">
|
||
<p class="text-xs uppercase tracking-widest text-brand-500">RSVP on file</p>
|
||
<h1 class="mb-1 text-2xl font-semibold">{{ access?.event.name }}</h1>
|
||
<p class="mb-5 text-sm text-zinc-400">
|
||
{{ access?.event.venue }} · {{ fmtDate(access?.event.event_date) }}
|
||
</p>
|
||
<p class="mb-4 text-sm">
|
||
You responded <strong class="capitalize">{{ existing.response }}</strong>
|
||
<span v-if="existing.plus_ones > 0"> with +{{ existing.plus_ones }} plus-ones</span>
|
||
on {{ fmtDate(existing.submitted_at) }}.
|
||
</p>
|
||
|
||
<AddToCalendar
|
||
v-if="calendar && existing.response === 'attending'"
|
||
:links="calendar"
|
||
:token="token"
|
||
class="mb-4 border-t border-zinc-800 pt-4"
|
||
/>
|
||
|
||
<div
|
||
v-if="access?.check_in && existing.response === 'attending'"
|
||
class="mb-4 border-t border-zinc-800 pt-4"
|
||
>
|
||
<p class="mb-2 text-xs font-medium uppercase tracking-widest text-brand-500">
|
||
Your door code
|
||
</p>
|
||
<p class="mb-3 text-sm text-zinc-300">
|
||
Show this at the entrance for a quick check-in on the day.
|
||
</p>
|
||
<div class="flex items-center justify-center rounded-md border border-zinc-800 bg-white p-3">
|
||
<img
|
||
:src="access.check_in.qr_image"
|
||
alt="Your check-in QR code"
|
||
class="h-44 w-44"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!editLimitReached" class="flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||
<p class="text-xs text-zinc-500">
|
||
{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
|
||
</p>
|
||
<button type="button" class="btn-ghost text-sm" @click="startEditing">Change my response</button>
|
||
</div>
|
||
<p v-else class="border-t border-zinc-800 pt-4 text-xs text-zinc-500">
|
||
You've used all {{ MAX_EDITS }} edits on this invitation.
|
||
</p>
|
||
</div>
|
||
|
||
<div v-else-if="access && showForm" class="card overflow-hidden p-0">
|
||
<!-- Cover image — only renders when the host uploaded one. -->
|
||
<div
|
||
v-if="branding?.cover_image_url"
|
||
class="h-32 w-full bg-cover bg-center"
|
||
:style="{ backgroundImage: `url(${branding.cover_image_url})` }"
|
||
></div>
|
||
|
||
<div class="p-6">
|
||
<div class="mb-3 flex items-center gap-3">
|
||
<img
|
||
v-if="branding?.logo_url"
|
||
:src="branding.logo_url"
|
||
alt=""
|
||
class="h-10 w-10 rounded object-contain bg-zinc-900"
|
||
/>
|
||
<div>
|
||
<!-- Eyebrow text takes the accent colour when the host set
|
||
one; otherwise falls back to the default brand green. -->
|
||
<p
|
||
class="text-xs uppercase tracking-widest"
|
||
:style="branding?.accent_color ? { color: 'var(--brand-accent)' } : undefined"
|
||
:class="branding?.accent_color ? '' : 'text-brand-500'"
|
||
>
|
||
{{ existing ? 'Update your response' : 'Invitation' }}
|
||
</p>
|
||
<h1 class="text-2xl font-semibold">{{ access.event.name }}</h1>
|
||
</div>
|
||
</div>
|
||
<p class="mb-4 text-sm text-zinc-400">
|
||
{{ access.event.venue }} · {{ fmtDate(access.event.event_date) }}
|
||
</p>
|
||
|
||
<p
|
||
v-if="greetingMessage"
|
||
class="mb-4 rounded-r border-l-[3px] bg-brand-500/[0.04] py-1.5 pl-3 pr-2 text-sm text-zinc-300"
|
||
:style="branding?.accent_color ? { borderColor: 'var(--brand-accent)' } : { borderColor: 'rgb(34 197 94 / 0.6)' }"
|
||
>{{ greetingMessage }}</p>
|
||
|
||
<p class="mb-6 text-sm">
|
||
Hi <span class="font-medium text-zinc-100">{{ access.guest.name }}</span> —
|
||
<template v-if="existing">change your response below — {{ editsRemaining }}
|
||
{{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.</template>
|
||
<template v-else>please confirm your response below.</template>
|
||
</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>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<button
|
||
class="btn-primary flex-1"
|
||
:style="branding?.primary_color ? { background: 'var(--brand-primary)' } : undefined"
|
||
:disabled="submitting"
|
||
@click="submit"
|
||
>
|
||
{{ submitLabel }}
|
||
</button>
|
||
<button v-if="existing" type="button" class="btn-ghost" :disabled="submitting" @click="cancelEditing">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
|
||
<p v-if="submitError" class="mt-3 text-sm text-red-400">{{ submitError }}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|