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
This commit is contained in:
chris 2026-07-12 12:18:12 -04:00
parent cb99fec940
commit a6d1ade53c
7 changed files with 199 additions and 13 deletions

View File

@ -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<RsvpEditState> {
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 } });

View File

@ -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
</tbody>
</table>
)}
<div className="mt-4">
<AddRsvpForm eventId={event.id} />
</div>
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>

View File

@ -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 (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
Add an RSVP
</button>
);
}
return (
<form action={formAction} className="flex flex-col gap-3 rounded-md border border-gray-200 p-4">
<p className="text-xs text-gray-500">
For guests who let you know their response directly instead of using the invite link.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-500">Name</label>
<input name="guestName" required className={inputClass} />
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Email or phone</label>
<input name="contact" required className={inputClass} />
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Status</label>
<select name="status" defaultValue="YES" className={inputClass}>
<option value="YES">Yes</option>
<option value="MAYBE">Maybe</option>
<option value="NO">Can&apos;t make it</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Guests</label>
<input name="numGuests" type="number" min={1} max={50} defaultValue={1} className={inputClass} />
</div>
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-gray-500">Note (optional)</label>
<input name="note" className={inputClass} />
</div>
</div>
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
<div className="flex gap-2">
<SubmitButton className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
Add RSVP
</SubmitButton>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
);
}

View File

@ -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 (
<form
action={async (formData) => {
await formAction(formData);
onDone();
}}
className="flex flex-col gap-2 rounded-md bg-gray-50 p-3"
>
<form action={formAction} className="flex flex-col gap-2 rounded-md bg-gray-50 p-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="col-span-2 sm:col-span-1">
<label className="block text-xs font-medium text-gray-500">Name</label>

View File

@ -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({
<div>{rsvp.guestName}</div>
<div className="text-xs text-gray-400">{rsvp.guestEmail ?? rsvp.guestPhone}</div>
</td>
<td className="py-2">{rsvp.status}</td>
<td className="py-2">
<RsvpStatusPill status={rsvp.status} />
</td>
<td className="py-2">{rsvp.numGuests}</td>
<td className="py-2 text-gray-500">{rsvp.note}</td>
<td className="py-2 text-right">
@ -53,7 +56,15 @@ export function RsvpRow({
Edit
</button>
<form action={deleteRsvpAction.bind(null, rsvp.id)}>
<button type="submit" className="text-red-500 hover:text-red-700">
<button
type="submit"
onClick={(e) => {
if (!confirm(`Remove ${rsvp.guestName}'s RSVP? This can't be undone.`)) {
e.preventDefault();
}
}}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</form>

View File

@ -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 (
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STYLES[status]}`}>
{LABELS[status]}
</span>
);
}

View File

@ -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),