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:
parent
cb99fec940
commit
a6d1ade53c
@ -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 } });
|
||||
|
||||
@ -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}>
|
||||
|
||||
85
src/components/AddRsvpForm.tsx
Normal file
85
src/components/AddRsvpForm.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
19
src/components/RsvpStatusPill.tsx
Normal file
19
src/components/RsvpStatusPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user