diff --git a/app/invite/[token]/actions.ts b/app/invite/[token]/actions.ts new file mode 100644 index 0000000..b24a31e --- /dev/null +++ b/app/invite/[token]/actions.ts @@ -0,0 +1,83 @@ +"use server" + +import { redirect } from "next/navigation" + +import { replaceInvitationInteractions } from "@/lib/db/queries/interactions" +import { getInvitationByToken, submitInvitationResponse } from "@/lib/db/queries/invitations" +import { isRsvpClosed } from "@/lib/rsvp-deadline" +import { interactionSubmissionSchema } from "@/lib/validation/interactions" +import { + mealPreferenceSchema, + submitInvitationResponseSchema, +} from "@/lib/validation/response" + +function optionalString(formData: FormData, key: string) { + return formData.get(key)?.toString() ?? "" +} + +function transportHelp(value: FormDataEntryValue | null) { + if (value === "yes") return true + if (value === "no") return false + return null +} + +function toastRequest(formData: FormData) { + if (!formData.has("wantsToast")) return null + + const speakerName = optionalString(formData, "toastSpeakerName") + if (!speakerName) return null + + return { + memberId: optionalString(formData, "toastMemberId") || null, + speakerName, + estimatedMinutes: optionalString(formData, "toastEstimatedMinutes"), + } +} + +export async function submitRsvpAction(token: string, formData: FormData) { + 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 meal = mealPreferenceSchema.safeParse( + formData.get(`meal-${member.id}`)?.toString() ?? "", + ) + + return { + memberId: member.id, + attending, + mealPreference: attending && meal.success ? meal.data : null, + dietaryRestrictions: optionalString(formData, `dietary-${member.id}`), + } + }) + + const response = submitInvitationResponseSchema.safeParse({ + token, + members, + arrivalDate: formData.get("arrivalDate"), + departureDate: formData.get("departureDate"), + stayLocation: formData.get("stayLocation"), + needsTransportHelp: transportHelp(formData.get("needsTransportHelp")), + messageToCouple: formData.get("messageToCouple"), + }) + const interactions = interactionSubmissionSchema.safeParse({ + invitationId: invitation.invitation.id, + toastRequest: toastRequest(formData), + }) + + if (!response.success || !interactions.success) { + redirect(`/invite/${token}?error=generic`) + } + + await submitInvitationResponse(response.data) + await replaceInvitationInteractions(interactions.data) + redirect(`/invite/${token}?submitted=1`) +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index 5003318..2f83a09 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -1,127 +1,16 @@ -import { redirect } from "next/navigation" - -import { - getInvitationInteractions, - replaceInvitationInteractions, -} from "@/lib/db/queries/interactions" -import { getInvitationByToken, submitInvitationResponse } from "@/lib/db/queries/invitations" +import { WeddingInvite } from "@/components/invite/wedding-invite" +import { getInvitationInteractions } from "@/lib/db/queries/interactions" +import { getInvitationByToken } 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" +import { isSupportedLanguage } from "@/lib/i18n/config" +import { getInvitePageContent } from "@/lib/i18n/invite-page/content" +import { isRsvpClosed } from "@/lib/rsvp-deadline" + +import { submitRsvpAction } from "./actions" 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, @@ -131,293 +20,17 @@ export default async function InvitePage({ }) { const [{ token }, { submitted, error }] = await Promise.all([params, searchParams]) const detail = await getInvitationByToken(token) + const dictionary = getGuestDictionary(detail?.invitation.language ?? "en") if (!detail) { - const dictionary = getGuestDictionary("en") - - return ( -
-
-

- {dictionary.hero.eyebrow} -

-

- {dictionary.invalid.title} -

-

{dictionary.invalid.description}

-
-
- ) + return

{dictionary.hero.eyebrow}

{dictionary.invalid.title}

{dictionary.invalid.description}

} - const dictionary = getGuestDictionary(detail.invitation.language) + const language = isSupportedLanguage(detail.invitation.language) ? detail.invitation.language : "en" 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 ( -
-
-
-

- {dictionary.hero.eyebrow} -

-

- {dictionary.hero.title} -

-

{greeting}

-

{dictionary.hero.description}

-
- - {submitted ? ( -
- {dictionary.form.submitted} -
- ) : null} - - {error ? ( -
- {error === "closed" ? dictionary.errors.closed : dictionary.errors.generic} -
- ) : null} - -
-
-

{dictionary.wedding.dateLabel}

-

{dictionary.wedding.dateValue}

-
-
-

{dictionary.wedding.locationLabel}

-

{dictionary.wedding.locationValue}

-
-
-

{dictionary.wedding.accommodationLabel}

-

{dictionary.wedding.accommodationValue}

-
-
- -
-
-
-

{dictionary.sections.rsvp}

-

- {closed - ? dictionary.deadline.closed - : deadlineMessage(dictionary.deadline.open)} -

-
- -
- {detail.members.map((member) => ( -
- {member.name} - -
-

{dictionary.form.attending}

-
- - -
-
- -
- - - -
-
- ))} -
-
- -
-
-

{dictionary.sections.travel}

-
- -
- - - - - - -
-

{dictionary.form.needsTransportHelp}

-
- - -
-
- -
+
Photo upload06

Add a favorite memory with us.

+

You’ll receive a confirmation once we’ve received your RSVP.

+
+ + + +
+ +
+ +