From a6d1ade53c6858be87cef3b6d399c97b964496a8 Mon Sep 17 00:00:00 2001 From: chris Date: Sun, 12 Jul 2026 12:18:12 -0400 Subject: [PATCH] Add manual RSVP entry, status pills, and delete confirmation - Hosts can record an RSVP for a guest who let them know directly (call, text, in person) instead of using the invite form - RSVP status now renders as a colored pill instead of plain text - Removing an RSVP now asks for confirmation, since it's a hard delete - Fixes a bug in the edit-RSVP form: useActionState's dispatch isn't awaitable, so the previous await-then-close logic could close the row and swallow a validation error before the update actually finished; now watches isPending settling instead --- src/app/actions/rsvp.ts | 57 ++++++++++++- src/app/dashboard/events/[id]/guests/page.tsx | 4 + src/components/AddRsvpForm.tsx | 85 +++++++++++++++++++ src/components/EditRsvpForm.tsx | 24 ++++-- src/components/RsvpRow.tsx | 15 +++- src/components/RsvpStatusPill.tsx | 19 +++++ src/lib/validation.ts | 8 ++ 7 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 src/components/AddRsvpForm.tsx create mode 100644 src/components/RsvpStatusPill.tsx diff --git a/src/app/actions/rsvp.ts b/src/app/actions/rsvp.ts index 2460af8..ffaadcb 100644 --- a/src/app/actions/rsvp.ts +++ b/src/app/actions/rsvp.ts @@ -3,13 +3,14 @@ import { notFound } from 'next/navigation'; import { revalidatePath } from 'next/cache'; import { prisma } from '@/lib/prisma'; -import { rsvpSchema, rsvpUpdateSchema } from '@/lib/validation'; +import { rsvpSchema, rsvpUpdateSchema, manualRsvpSchema } from '@/lib/validation'; import { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail'; import { appUrl } from '@/lib/slug'; import { checkRateLimit, getClientIp } from '@/lib/rateLimit'; import { sendPushToUser } from '@/lib/push'; -import { accessibleEventWhere } from '@/lib/eventAccess'; +import { accessibleEventWhere, requireEventAccess } from '@/lib/eventAccess'; import { requireUser } from '@/lib/session'; +import { looksLikeEmail, normalizePhoneNumber } from '@/lib/phone'; export type ActionState = { error?: string; success?: string }; @@ -163,6 +164,58 @@ export async function updateRsvpAction( return {}; } +/** + * Records an RSVP the host heard about outside the app (a text, a call, a + * comment at the mailbox) rather than through the guest submitting the + * form themselves. Upserts on the same email/phone key the guest-facing + * form uses, so if that guest later does submit the form it just updates + * this row instead of creating a duplicate. + */ +export async function addRsvpAction( + eventId: string, + _prev: RsvpEditState, + formData: FormData +): Promise { + const { event } = await requireEventAccess(eventId); + + const parsed = manualRsvpSchema.safeParse({ + guestName: formData.get('guestName'), + contact: formData.get('contact'), + status: formData.get('status'), + numGuests: formData.get('numGuests') || '1', + note: formData.get('note') ?? '', + }); + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Invalid input' }; + } + const data = parsed.data; + + let guestEmail: string | null = null; + let guestPhone: string | null = null; + if (looksLikeEmail(data.contact)) { + const emailResult = rsvpSchema.shape.guestEmail.safeParse(data.contact); + if (!emailResult.success) return { error: 'Enter a valid email address' }; + guestEmail = emailResult.data.toLowerCase(); + } else { + guestPhone = normalizePhoneNumber(data.contact); + if (!guestPhone) return { error: 'Enter a valid email address or phone number' }; + } + + const numGuests = data.status === 'NO' ? 1 : data.numGuests; + + await prisma.rsvp.upsert({ + where: guestEmail + ? { eventId_guestEmail: { eventId: event.id, guestEmail } } + : { eventId_guestPhone: { eventId: event.id, guestPhone: guestPhone as string } }, + create: { eventId: event.id, guestName: data.guestName, guestEmail, guestPhone, status: data.status, numGuests, note: data.note }, + update: { guestName: data.guestName, status: data.status, numGuests, note: data.note }, + }); + + revalidatePath(`/dashboard/events/${event.id}/guests`); + revalidatePath(`/invite/${event.slug}`); + return {}; +} + export async function deleteRsvpAction(rsvpId: string) { const rsvp = await requireOwnedRsvp(rsvpId); await prisma.rsvp.delete({ where: { id: rsvp.id } }); diff --git a/src/app/dashboard/events/[id]/guests/page.tsx b/src/app/dashboard/events/[id]/guests/page.tsx index 068e1c5..9db5528 100644 --- a/src/app/dashboard/events/[id]/guests/page.tsx +++ b/src/app/dashboard/events/[id]/guests/page.tsx @@ -18,6 +18,7 @@ import { formatEventDateRange } from '@/lib/format'; import { isPhotoUploadOpen } from '@/lib/photoWindow'; import { photoUrl } from '@/lib/photoStorage'; import { RsvpRow } from '@/components/RsvpRow'; +import { AddRsvpForm } from '@/components/AddRsvpForm'; export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -219,6 +220,9 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id )} +
+ +
diff --git a/src/components/AddRsvpForm.tsx b/src/components/AddRsvpForm.tsx new file mode 100644 index 0000000..bb967ff --- /dev/null +++ b/src/components/AddRsvpForm.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useActionState, useEffect, useRef, useState } from 'react'; +import { addRsvpAction, type RsvpEditState } from '@/app/actions/rsvp'; +import { SubmitButton } from '@/components/SubmitButton'; + +const initialState: RsvpEditState = {}; + +const inputClass = + 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none'; + +export function AddRsvpForm({ eventId }: { eventId: string }) { + const action = addRsvpAction.bind(null, eventId); + const [state, formAction, isPending] = useActionState(action, initialState); + const [open, setOpen] = useState(false); + + // See EditRsvpForm for why we track isPending instead of awaiting + // formAction directly: its dispatch doesn't return a promise. + const wasPending = useRef(false); + useEffect(() => { + if (wasPending.current && !isPending && !state.error) { + setOpen(false); + } + wasPending.current = isPending; + }, [isPending, state.error]); + + if (!open) { + return ( + + ); + } + + return ( +
+

+ For guests who let you know their response directly instead of using the invite link. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {state.error &&

{state.error}

} +
+ + Add RSVP + + +
+
+ ); +} diff --git a/src/components/EditRsvpForm.tsx b/src/components/EditRsvpForm.tsx index d621f03..dedce45 100644 --- a/src/components/EditRsvpForm.tsx +++ b/src/components/EditRsvpForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useActionState } from 'react'; +import { useActionState, useEffect, useRef } from 'react'; import { updateRsvpAction, type RsvpEditState } from '@/app/actions/rsvp'; import { SubmitButton } from '@/components/SubmitButton'; @@ -25,16 +25,22 @@ export function EditRsvpForm({ onDone: () => void; }) { const action = updateRsvpAction.bind(null, rsvpId); - const [state, formAction] = useActionState(action, initialState); + const [state, formAction, isPending] = useActionState(action, initialState); + + // useActionState's dispatch doesn't return a promise we can await, so we + // can't just chain onDone() after calling it - instead watch isPending + // fall back to false (the submission finished) and only close if it + // succeeded, so a validation error stays visible. + const wasPending = useRef(false); + useEffect(() => { + if (wasPending.current && !isPending && !state.error) { + onDone(); + } + wasPending.current = isPending; + }, [isPending, state.error, onDone]); return ( -
{ - await formAction(formData); - onDone(); - }} - className="flex flex-col gap-2 rounded-md bg-gray-50 p-3" - > +
diff --git a/src/components/RsvpRow.tsx b/src/components/RsvpRow.tsx index 9840ce8..09c6255 100644 --- a/src/components/RsvpRow.tsx +++ b/src/components/RsvpRow.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { deleteRsvpAction } from '@/app/actions/rsvp'; import { EditRsvpForm } from '@/components/EditRsvpForm'; +import { RsvpStatusPill } from '@/components/RsvpStatusPill'; type Status = 'YES' | 'NO' | 'MAYBE'; @@ -44,7 +45,9 @@ export function RsvpRow({
{rsvp.guestName}
{rsvp.guestEmail ?? rsvp.guestPhone}
- {rsvp.status} + + + {rsvp.numGuests} {rsvp.note} @@ -53,7 +56,15 @@ export function RsvpRow({ Edit - diff --git a/src/components/RsvpStatusPill.tsx b/src/components/RsvpStatusPill.tsx new file mode 100644 index 0000000..24ade43 --- /dev/null +++ b/src/components/RsvpStatusPill.tsx @@ -0,0 +1,19 @@ +const STYLES: Record<'YES' | 'NO' | 'MAYBE', string> = { + YES: 'bg-green-100 text-green-800', + MAYBE: 'bg-amber-100 text-amber-800', + NO: 'bg-gray-100 text-gray-600', +}; + +const LABELS: Record<'YES' | 'NO' | 'MAYBE', string> = { + YES: 'Yes', + MAYBE: 'Maybe', + NO: "Can't make it", +}; + +export function RsvpStatusPill({ status }: { status: 'YES' | 'NO' | 'MAYBE' }) { + return ( + + {LABELS[status]} + + ); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index a67a25f..d3b9798 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -95,6 +95,14 @@ export const rsvpUpdateSchema = z.object({ note: z.string().trim().max(1000).default(''), }); +export const manualRsvpSchema = z.object({ + guestName: z.string().trim().min(1, 'Name is required').max(150), + contact: z.string().trim().min(1, 'Enter an email or phone number'), + status: z.enum(['YES', 'NO', 'MAYBE']), + numGuests: z.coerce.number().int().min(1).max(50), + note: z.string().trim().max(1000).default(''), +}); + export const commentSchema = z.object({ eventId: z.string().min(1), authorName: z.string().trim().min(1, 'Name is required').max(150),