Let hosts edit or remove individual RSVPs
Hosts can now fix a guest's headcount, correct their name/note, change their status, or remove an RSVP entirely from the guest list page - covers cases where a guest messages the host directly instead of resubmitting the form.
This commit is contained in:
parent
2c78112a4f
commit
cb99fec940
@ -1,12 +1,15 @@
|
||||
'use server';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { rsvpSchema } from '@/lib/validation';
|
||||
import { rsvpSchema, rsvpUpdateSchema } 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 { requireUser } from '@/lib/session';
|
||||
|
||||
export type ActionState = { error?: string; success?: string };
|
||||
|
||||
@ -111,3 +114,58 @@ export async function submitRsvpAction(_prev: ActionState, formData: FormData):
|
||||
revalidatePath(`/invite/${event.slug}`);
|
||||
return { success: SUCCESS_MESSAGE };
|
||||
}
|
||||
|
||||
async function requireOwnedRsvp(rsvpId: string) {
|
||||
const user = await requireUser();
|
||||
const rsvp = await prisma.rsvp.findUnique({ where: { id: rsvpId }, include: { event: true } });
|
||||
if (!rsvp) notFound();
|
||||
|
||||
const accessibleEvent = await prisma.event.findFirst({
|
||||
where: accessibleEventWhere(rsvp.eventId, user.id),
|
||||
});
|
||||
if (!accessibleEvent) notFound();
|
||||
|
||||
return rsvp;
|
||||
}
|
||||
|
||||
export type RsvpEditState = { error?: string };
|
||||
|
||||
export async function updateRsvpAction(
|
||||
rsvpId: string,
|
||||
_prev: RsvpEditState,
|
||||
formData: FormData
|
||||
): Promise<RsvpEditState> {
|
||||
const rsvp = await requireOwnedRsvp(rsvpId);
|
||||
|
||||
const parsed = rsvpUpdateSchema.safeParse({
|
||||
guestName: formData.get('guestName'),
|
||||
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;
|
||||
|
||||
await prisma.rsvp.update({
|
||||
where: { id: rsvp.id },
|
||||
data: {
|
||||
guestName: data.guestName,
|
||||
status: data.status,
|
||||
numGuests: data.status === 'NO' ? 1 : data.numGuests,
|
||||
note: data.note,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/dashboard/events/${rsvp.eventId}/guests`);
|
||||
revalidatePath(`/invite/${rsvp.event.slug}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function deleteRsvpAction(rsvpId: string) {
|
||||
const rsvp = await requireOwnedRsvp(rsvpId);
|
||||
await prisma.rsvp.delete({ where: { id: rsvp.id } });
|
||||
revalidatePath(`/dashboard/events/${rsvp.eventId}/guests`);
|
||||
revalidatePath(`/invite/${rsvp.event.slug}`);
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import { togglePhotoHiddenAction, deletePhotoAction, toggleForceCloseAction } fr
|
||||
import { formatEventDateRange } from '@/lib/format';
|
||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||
import { photoUrl } from '@/lib/photoStorage';
|
||||
import { RsvpRow } from '@/components/RsvpRow';
|
||||
|
||||
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
@ -208,19 +209,12 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
||||
<th className="py-2 font-medium">Status</th>
|
||||
<th className="py-2 font-medium">Guests</th>
|
||||
<th className="py-2 font-medium">Note</th>
|
||||
<th className="py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{event.rsvps.map((rsvp) => (
|
||||
<tr key={rsvp.id} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<div>{rsvp.guestName}</div>
|
||||
<div className="text-xs text-gray-400">{rsvp.guestEmail}</div>
|
||||
</td>
|
||||
<td className="py-2">{rsvp.status}</td>
|
||||
<td className="py-2">{rsvp.numGuests}</td>
|
||||
<td className="py-2 text-gray-500">{rsvp.note}</td>
|
||||
</tr>
|
||||
<RsvpRow key={rsvp.id} rsvp={rsvp} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
82
src/components/EditRsvpForm.tsx
Normal file
82
src/components/EditRsvpForm.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { updateRsvpAction, type RsvpEditState } from '@/app/actions/rsvp';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: RsvpEditState = {};
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none';
|
||||
|
||||
export function EditRsvpForm({
|
||||
rsvpId,
|
||||
guestName,
|
||||
status,
|
||||
numGuests,
|
||||
note,
|
||||
onDone,
|
||||
}: {
|
||||
rsvpId: string;
|
||||
guestName: string;
|
||||
status: 'YES' | 'NO' | 'MAYBE';
|
||||
numGuests: number;
|
||||
note: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const action = updateRsvpAction.bind(null, rsvpId);
|
||||
const [state, formAction] = useActionState(action, initialState);
|
||||
|
||||
return (
|
||||
<form
|
||||
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="col-span-2 sm:col-span-1">
|
||||
<label className="block text-xs font-medium text-gray-500">Name</label>
|
||||
<input name="guestName" defaultValue={guestName} required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500">Status</label>
|
||||
<select name="status" defaultValue={status} 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={numGuests}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<label className="block text-xs font-medium text-gray-500">Note</label>
|
||||
<input name="note" defaultValue={note} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
{state.error && <p className="text-xs text-red-700">{state.error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<SubmitButton className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
|
||||
Save
|
||||
</SubmitButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
64
src/components/RsvpRow.tsx
Normal file
64
src/components/RsvpRow.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { deleteRsvpAction } from '@/app/actions/rsvp';
|
||||
import { EditRsvpForm } from '@/components/EditRsvpForm';
|
||||
|
||||
type Status = 'YES' | 'NO' | 'MAYBE';
|
||||
|
||||
export function RsvpRow({
|
||||
rsvp,
|
||||
}: {
|
||||
rsvp: {
|
||||
id: string;
|
||||
guestName: string;
|
||||
guestEmail: string | null;
|
||||
guestPhone: string | null;
|
||||
status: Status;
|
||||
numGuests: number;
|
||||
note: string;
|
||||
};
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<tr className="border-b last:border-0">
|
||||
<td colSpan={5} className="py-2">
|
||||
<EditRsvpForm
|
||||
rsvpId={rsvp.id}
|
||||
guestName={rsvp.guestName}
|
||||
status={rsvp.status}
|
||||
numGuests={rsvp.numGuests}
|
||||
note={rsvp.note}
|
||||
onDone={() => setEditing(false)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<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">{rsvp.numGuests}</td>
|
||||
<td className="py-2 text-gray-500">{rsvp.note}</td>
|
||||
<td className="py-2 text-right">
|
||||
<div className="flex justify-end gap-3 text-xs">
|
||||
<button type="button" onClick={() => setEditing(true)} className="text-gray-500 hover:text-gray-800">
|
||||
Edit
|
||||
</button>
|
||||
<form action={deleteRsvpAction.bind(null, rsvp.id)}>
|
||||
<button type="submit" className="text-red-500 hover:text-red-700">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@ -88,6 +88,13 @@ export const rsvpSchema = z.object({
|
||||
note: z.string().trim().max(1000).default(''),
|
||||
});
|
||||
|
||||
export const rsvpUpdateSchema = z.object({
|
||||
guestName: z.string().trim().min(1, 'Name is required').max(150),
|
||||
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