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 { notFound } from 'next/navigation';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { prisma } from '@/lib/prisma';
|
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 { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail';
|
||||||
import { appUrl } from '@/lib/slug';
|
import { appUrl } from '@/lib/slug';
|
||||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||||
import { sendPushToUser } from '@/lib/push';
|
import { sendPushToUser } from '@/lib/push';
|
||||||
import { accessibleEventWhere } from '@/lib/eventAccess';
|
import { accessibleEventWhere, requireEventAccess } from '@/lib/eventAccess';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { looksLikeEmail, normalizePhoneNumber } from '@/lib/phone';
|
||||||
|
|
||||||
export type ActionState = { error?: string; success?: string };
|
export type ActionState = { error?: string; success?: string };
|
||||||
|
|
||||||
@ -163,6 +164,58 @@ export async function updateRsvpAction(
|
|||||||
return {};
|
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) {
|
export async function deleteRsvpAction(rsvpId: string) {
|
||||||
const rsvp = await requireOwnedRsvp(rsvpId);
|
const rsvp = await requireOwnedRsvp(rsvpId);
|
||||||
await prisma.rsvp.delete({ where: { id: rsvp.id } });
|
await prisma.rsvp.delete({ where: { id: rsvp.id } });
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { formatEventDateRange } from '@/lib/format';
|
|||||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||||
import { photoUrl } from '@/lib/photoStorage';
|
import { photoUrl } from '@/lib/photoStorage';
|
||||||
import { RsvpRow } from '@/components/RsvpRow';
|
import { RsvpRow } from '@/components/RsvpRow';
|
||||||
|
import { AddRsvpForm } from '@/components/AddRsvpForm';
|
||||||
|
|
||||||
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@ -219,6 +220,9 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddRsvpForm eventId={event.id} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
<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';
|
'use client';
|
||||||
|
|
||||||
import { useActionState } from 'react';
|
import { useActionState, useEffect, useRef } from 'react';
|
||||||
import { updateRsvpAction, type RsvpEditState } from '@/app/actions/rsvp';
|
import { updateRsvpAction, type RsvpEditState } from '@/app/actions/rsvp';
|
||||||
import { SubmitButton } from '@/components/SubmitButton';
|
import { SubmitButton } from '@/components/SubmitButton';
|
||||||
|
|
||||||
@ -25,16 +25,22 @@ export function EditRsvpForm({
|
|||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const action = updateRsvpAction.bind(null, rsvpId);
|
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 (
|
return (
|
||||||
<form
|
<form action={formAction} className="flex flex-col gap-2 rounded-md bg-gray-50 p-3">
|
||||||
action={async (formData) => {
|
|
||||||
await formAction(formData);
|
|
||||||
onDone();
|
|
||||||
}}
|
|
||||||
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="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
<div className="col-span-2 sm:col-span-1">
|
<div className="col-span-2 sm:col-span-1">
|
||||||
<label className="block text-xs font-medium text-gray-500">Name</label>
|
<label className="block text-xs font-medium text-gray-500">Name</label>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { deleteRsvpAction } from '@/app/actions/rsvp';
|
import { deleteRsvpAction } from '@/app/actions/rsvp';
|
||||||
import { EditRsvpForm } from '@/components/EditRsvpForm';
|
import { EditRsvpForm } from '@/components/EditRsvpForm';
|
||||||
|
import { RsvpStatusPill } from '@/components/RsvpStatusPill';
|
||||||
|
|
||||||
type Status = 'YES' | 'NO' | 'MAYBE';
|
type Status = 'YES' | 'NO' | 'MAYBE';
|
||||||
|
|
||||||
@ -44,7 +45,9 @@ export function RsvpRow({
|
|||||||
<div>{rsvp.guestName}</div>
|
<div>{rsvp.guestName}</div>
|
||||||
<div className="text-xs text-gray-400">{rsvp.guestEmail ?? rsvp.guestPhone}</div>
|
<div className="text-xs text-gray-400">{rsvp.guestEmail ?? rsvp.guestPhone}</div>
|
||||||
</td>
|
</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">{rsvp.numGuests}</td>
|
||||||
<td className="py-2 text-gray-500">{rsvp.note}</td>
|
<td className="py-2 text-gray-500">{rsvp.note}</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
@ -53,7 +56,15 @@ export function RsvpRow({
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<form action={deleteRsvpAction.bind(null, rsvp.id)}>
|
<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
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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(''),
|
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({
|
export const commentSchema = z.object({
|
||||||
eventId: z.string().min(1),
|
eventId: z.string().min(1),
|
||||||
authorName: z.string().trim().min(1, 'Name is required').max(150),
|
authorName: z.string().trim().min(1, 'Name is required').max(150),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user