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}
-
-
-
-
-
-
- )
+ return
}
diff --git a/app/invite/layout.tsx b/app/invite/layout.tsx
index a1a677f..6aa6b14 100644
--- a/app/invite/layout.tsx
+++ b/app/invite/layout.tsx
@@ -1,5 +1,5 @@
export default function InviteLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
- return
{children}
+ return {children}
}
diff --git a/components/invite/details-section.tsx b/components/invite/details-section.tsx
new file mode 100644
index 0000000..d8dfca5
--- /dev/null
+++ b/components/invite/details-section.tsx
@@ -0,0 +1,30 @@
+import Image from "next/image"
+
+import { SectionHeading } from "@/components/invite/section-heading"
+import { WeddingContainer } from "@/components/invite/wedding-container"
+import type { InvitePageContent } from "@/lib/i18n/invite-page/content"
+
+const expectationImages = ["/images/details_ceremony.png", "/images/details_dinner.png"]
+
+export function DetailsSection({ content }: { content: InvitePageContent["details"] }) {
+ return (
+
+
+
+
+ {content.facts.map((fact, index) =>
0{index + 1} · {fact.label}
{fact.value}
{fact.description}
)}
+
+
+ {content.expectations.map((item, index) =>
{item.label}
{item.value}
{item.description}
)}
+
+ {content.dressCode.label}
{content.dressCode.value}
{content.dressCode.description}
+
+ {content.policies.map((policy) =>
{policy.title}
{policy.description}
)}
+
+
+
+ )
+}
diff --git a/components/invite/faq-section.tsx b/components/invite/faq-section.tsx
new file mode 100644
index 0000000..2fca0bc
--- /dev/null
+++ b/components/invite/faq-section.tsx
@@ -0,0 +1,22 @@
+import Link from "next/link"
+
+import { SectionHeading } from "@/components/invite/section-heading"
+import { WeddingContainer } from "@/components/invite/wedding-container"
+import type { InvitePageContent } from "@/lib/i18n/invite-page/content"
+
+export function FaqSection({ content }: { content: InvitePageContent["faq"] }) {
+ return (
+
+
+
+
+ {content.questions.map((item) =>
{item.question}+
{item.answer}
)}
+
+
+
+
+ )
+}
diff --git a/components/invite/feature-split.tsx b/components/invite/feature-split.tsx
new file mode 100644
index 0000000..42195ee
--- /dev/null
+++ b/components/invite/feature-split.tsx
@@ -0,0 +1,27 @@
+import Image from "next/image"
+import type { ReactNode } from "react"
+
+export function FeatureSplit({
+ children,
+ image,
+ alt,
+ reverse = false,
+}: {
+ children: ReactNode
+ image: string
+ alt: string
+ reverse?: boolean
+}) {
+ return (
+
+ )
+}
diff --git a/components/invite/heart-quote.tsx b/components/invite/heart-quote.tsx
new file mode 100644
index 0000000..79c20e9
--- /dev/null
+++ b/components/invite/heart-quote.tsx
@@ -0,0 +1,14 @@
+export function HeartQuote({ quote }: { quote: string }) {
+ return (
+
+
+ “{quote}”
+
+
+
+ ♥
+
+
+
+ )
+}
diff --git a/components/invite/hero-section.tsx b/components/invite/hero-section.tsx
new file mode 100644
index 0000000..c06d43a
--- /dev/null
+++ b/components/invite/hero-section.tsx
@@ -0,0 +1,27 @@
+import Image from "next/image"
+import Link from "next/link"
+
+import { SiteHeader } from "@/components/invite/site-header"
+import type { InvitePageContent } from "@/lib/i18n/invite-page/content"
+
+export function HeroSection({ content }: { content: InvitePageContent["hero"] }) {
+ return (
+
+
+
+
+
+
+
+
{content.eyebrow}
+
+ {content.title}
+
+
{content.subtitle}
+
+ Scroll
↓
+
+
+
+ )
+}
diff --git a/components/invite/introduction-section.tsx b/components/invite/introduction-section.tsx
new file mode 100644
index 0000000..b992d06
--- /dev/null
+++ b/components/invite/introduction-section.tsx
@@ -0,0 +1,38 @@
+import Image from "next/image"
+import { CalendarDays, Clock3, MapPin } from "lucide-react"
+
+import { Monogram } from "@/components/invite/monogram"
+import { WeddingContainer } from "@/components/invite/wedding-container"
+import type { InvitePageContent } from "@/lib/i18n/invite-page/content"
+
+const icons = [CalendarDays, MapPin, Clock3]
+
+export function IntroductionSection({ content }: { content: InvitePageContent["introduction"] }) {
+ return (
+
+
+
+
+
+
{content.heading}
+
+ {content.paragraphs.map((paragraph) =>
{paragraph}
)}
+
Nicole & Raul
+
+
+
+
+
+
+ {content.facts.map((fact, index) => {
+ const Icon = icons[index]
+ return
+
+
{fact.label}
{fact.value}
{fact.description ?
{fact.description}
: null}
+
+ })}
+
+
+
+ )
+}
diff --git a/components/invite/monogram.tsx b/components/invite/monogram.tsx
new file mode 100644
index 0000000..4539a5e
--- /dev/null
+++ b/components/invite/monogram.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+
+export function Monogram({ light = false, className }: { light?: boolean; className?: string }) {
+ return (
+
+ N | R
+
+ )
+}
diff --git a/components/invite/photo-upload.tsx b/components/invite/photo-upload.tsx
index 9c24a84..e697c3a 100644
--- a/components/invite/photo-upload.tsx
+++ b/components/invite/photo-upload.tsx
@@ -99,40 +99,43 @@ export function PhotoUpload({
}
return (
-
-
-
{labels.title}
-
{labels.description}
-
{labels.requirements}
+
+
+
04
+
+
{labels.title}
+
{labels.description}
+
+
{labels.requirements}
-
+
{filename ? (
-
+
{labels.current.replace("{filename}", filename)}
) : null}
{disabled ? (
-
+
{labels.disabled}
) : (
-
-