This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import {
|
||||
getInvitationInteractions,
|
||||
replaceInvitationInteractions,
|
||||
} from "@/lib/db/queries/interactions"
|
||||
import { getInvitationByToken, submitInvitationResponse } from "@/lib/db/queries/invitations"
|
||||
import { getPhotoUploadByInvitationId } from "@/lib/db/queries/uploads"
|
||||
import { getGuestDictionary } from "@/lib/i18n/get-dictionary"
|
||||
import { getRsvpDeadline, isRsvpClosed } from "@/lib/rsvp-deadline"
|
||||
import { PhotoUpload } from "@/components/invite/photo-upload"
|
||||
import {
|
||||
interactionSubmissionSchema,
|
||||
} from "@/lib/validation/interactions"
|
||||
import {
|
||||
mealPreferenceSchema,
|
||||
submitInvitationResponseSchema,
|
||||
type MealPreference,
|
||||
} from "@/lib/validation/response"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const mealOptions: MealPreference[] = ["meat", "fish", "vegetarian", "vegan", "kids", "other"]
|
||||
|
||||
function formatDateForInput(value: Date | string | null | undefined) {
|
||||
if (!value) return ""
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function getOptionalString(formData: FormData, key: string) {
|
||||
return formData.get(key)?.toString() ?? ""
|
||||
}
|
||||
|
||||
function getTransportHelp(value: FormDataEntryValue | null) {
|
||||
if (value === "yes") return true
|
||||
if (value === "no") return false
|
||||
return null
|
||||
}
|
||||
|
||||
function getToastRequestFromFormData(formData: FormData) {
|
||||
if (!formData.has("wantsToast")) return null
|
||||
|
||||
const speakerName = getOptionalString(formData, "toastSpeakerName")
|
||||
if (!speakerName) return null
|
||||
|
||||
const memberId = getOptionalString(formData, "toastMemberId")
|
||||
|
||||
return {
|
||||
memberId: memberId || null,
|
||||
speakerName,
|
||||
estimatedMinutes: getOptionalString(formData, "toastEstimatedMinutes"),
|
||||
}
|
||||
}
|
||||
|
||||
function deadlineMessage(template: string) {
|
||||
const deadline = getRsvpDeadline()
|
||||
return template.replace("{date}", deadline ?? "the deadline")
|
||||
}
|
||||
|
||||
async function submitRsvpAction(token: string, formData: FormData) {
|
||||
"use server"
|
||||
|
||||
if (isRsvpClosed()) {
|
||||
redirect(`/invite/${token}?error=closed`)
|
||||
}
|
||||
|
||||
const invitation = await getInvitationByToken(token)
|
||||
|
||||
if (!invitation) {
|
||||
redirect(`/invite/${token}?error=invalid`)
|
||||
}
|
||||
|
||||
const members = invitation.members.map((member) => {
|
||||
const attendingValue = formData.get(`attending-${member.id}`)
|
||||
|
||||
if (attendingValue !== "yes" && attendingValue !== "no") {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
const attending = attendingValue === "yes"
|
||||
const rawMealPreference = formData.get(`meal-${member.id}`)?.toString() ?? ""
|
||||
const mealPreference = mealPreferenceSchema.safeParse(rawMealPreference)
|
||||
|
||||
return {
|
||||
memberId: member.id,
|
||||
attending,
|
||||
mealPreference: attending && mealPreference.success ? mealPreference.data : null,
|
||||
dietaryRestrictions: getOptionalString(formData, `dietary-${member.id}`),
|
||||
}
|
||||
})
|
||||
|
||||
const parsed = submitInvitationResponseSchema.safeParse({
|
||||
token,
|
||||
members,
|
||||
arrivalDate: formData.get("arrivalDate"),
|
||||
departureDate: formData.get("departureDate"),
|
||||
stayLocation: formData.get("stayLocation"),
|
||||
needsTransportHelp: getTransportHelp(formData.get("needsTransportHelp")),
|
||||
messageToCouple: formData.get("messageToCouple"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
const interactions = interactionSubmissionSchema.safeParse({
|
||||
invitationId: invitation.invitation.id,
|
||||
toastRequest: getToastRequestFromFormData(formData),
|
||||
})
|
||||
|
||||
if (!interactions.success) {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
await submitInvitationResponse(parsed.data)
|
||||
await replaceInvitationInteractions(interactions.data)
|
||||
redirect(`/invite/${token}?submitted=1`)
|
||||
}
|
||||
|
||||
export default async function InvitePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ token: string }>
|
||||
searchParams: Promise<{ submitted?: string; error?: string }>
|
||||
}) {
|
||||
const [{ token }, { submitted, error }] = await Promise.all([params, searchParams])
|
||||
const detail = await getInvitationByToken(token)
|
||||
|
||||
if (!detail) {
|
||||
const dictionary = getGuestDictionary("en")
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-stone-50 px-6 py-12">
|
||||
<section className="max-w-lg rounded-3xl border bg-white p-8 text-center shadow-sm">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-stone-500">
|
||||
{dictionary.hero.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold text-stone-950">
|
||||
{dictionary.invalid.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-stone-600">{dictionary.invalid.description}</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const dictionary = getGuestDictionary(detail.invitation.language)
|
||||
const [interactions, photoUpload] = await Promise.all([
|
||||
getInvitationInteractions(detail.invitation.id),
|
||||
getPhotoUploadByInvitationId(detail.invitation.id),
|
||||
])
|
||||
const closed = isRsvpClosed()
|
||||
const action = submitRsvpAction.bind(null, token)
|
||||
const greeting = dictionary.hero.greeting.replace("{name}", detail.invitation.display_name)
|
||||
const toastRequest = interactions.toastRequests[0]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 text-stone-950">
|
||||
<section className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-12">
|
||||
<div className="rounded-[2rem] bg-white p-8 shadow-sm ring-1 ring-stone-200 md:p-12">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.3em] text-stone-500">
|
||||
{dictionary.hero.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-5 max-w-3xl text-4xl font-semibold tracking-tight md:text-6xl">
|
||||
{dictionary.hero.title}
|
||||
</h1>
|
||||
<p className="mt-6 text-xl font-medium text-stone-800">{greeting}</p>
|
||||
<p className="mt-3 max-w-2xl text-stone-600">{dictionary.hero.description}</p>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-emerald-950">
|
||||
{dictionary.form.submitted}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-red-950">
|
||||
{error === "closed" ? dictionary.errors.closed : dictionary.errors.generic}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.dateLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.dateValue}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.locationLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.locationValue}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.accommodationLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.accommodationValue}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form action={action} className="space-y-8">
|
||||
<section className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="flex flex-col gap-2 border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.rsvp}</h2>
|
||||
<p className="text-stone-600">
|
||||
{closed
|
||||
? dictionary.deadline.closed
|
||||
: deadlineMessage(dictionary.deadline.open)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{detail.members.map((member) => (
|
||||
<fieldset className="space-y-5 py-6" disabled={closed} key={member.id}>
|
||||
<legend className="text-lg font-semibold">{member.name}</legend>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{dictionary.form.attending}</p>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={`attending-${member.id}`}
|
||||
value="yes"
|
||||
defaultChecked={member.attending === true}
|
||||
required
|
||||
/>
|
||||
{dictionary.form.yes}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={`attending-${member.id}`}
|
||||
value="no"
|
||||
defaultChecked={member.attending === false}
|
||||
required
|
||||
/>
|
||||
{dictionary.form.no}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.mealPreference}</span>
|
||||
<select
|
||||
name={`meal-${member.id}`}
|
||||
defaultValue={member.meal_preference ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
>
|
||||
<option value="">{dictionary.form.mealPlaceholder}</option>
|
||||
{mealOptions.map((meal) => (
|
||||
<option key={meal} value={meal}>
|
||||
{dictionary.meals[meal]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.dietaryRestrictions}</span>
|
||||
<input
|
||||
name={`dietary-${member.id}`}
|
||||
defaultValue={member.dietary_restrictions ?? ""}
|
||||
placeholder={dictionary.form.noRestrictions}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<fieldset disabled={closed} className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.travel}</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.arrivalDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
name="arrivalDate"
|
||||
defaultValue={formatDateForInput(detail.response?.arrival_date)}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.departureDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
name="departureDate"
|
||||
defaultValue={formatDateForInput(detail.response?.departure_date)}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.stayLocation}</span>
|
||||
<input
|
||||
name="stayLocation"
|
||||
defaultValue={detail.response?.stay_location ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{dictionary.form.needsTransportHelp}</p>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="needsTransportHelp"
|
||||
value="yes"
|
||||
defaultChecked={detail.response?.needs_transport_help === true}
|
||||
/>
|
||||
{dictionary.form.yes}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="needsTransportHelp"
|
||||
value="no"
|
||||
defaultChecked={detail.response?.needs_transport_help === false}
|
||||
/>
|
||||
{dictionary.form.no}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.messageToCouple}</span>
|
||||
<textarea
|
||||
name="messageToCouple"
|
||||
defaultValue={detail.response?.message_to_couple ?? ""}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset disabled={closed} className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.interactions}</h2>
|
||||
<p className="mt-2 text-stone-600">{dictionary.interactions.intro}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-10">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{dictionary.interactions.toastTitle}</h3>
|
||||
<p className="text-sm text-stone-600">
|
||||
{dictionary.interactions.toastDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="wantsToast" defaultChecked={Boolean(toastRequest)} />
|
||||
{dictionary.interactions.wantsToast}
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.interactions.speakerName}</span>
|
||||
<input
|
||||
name="toastSpeakerName"
|
||||
defaultValue={toastRequest?.speaker_name ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.interactions.estimatedMinutes}</span>
|
||||
<input
|
||||
name="toastEstimatedMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={toastRequest?.estimated_minutes ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<select name="toastMemberId" defaultValue={toastRequest?.member_id ?? ""} className="hidden">
|
||||
<option value="" />
|
||||
{detail.members.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<PhotoUpload
|
||||
token={token}
|
||||
labels={dictionary.upload}
|
||||
disabled={closed}
|
||||
existingFilename={photoUpload?.original_filename ?? null}
|
||||
/>
|
||||
|
||||
{!closed ? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-stone-950 px-6 py-3 font-medium text-white transition hover:bg-stone-800"
|
||||
>
|
||||
{dictionary.form.submit}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user