feat: initial draft and components creation
@@ -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`)
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
return <main className="flex min-h-screen items-center justify-center bg-wedding-ivory px-wedding-gutter-mobile"><section className="max-w-lg border border-wedding-line bg-wedding-cream p-8 text-center"><p className="wedding-section-label">{dictionary.hero.eyebrow}</p><h1 className="wedding-display-heading mt-5 text-wedding-section">{dictionary.invalid.title}</h1><p className="mt-5 font-wedding-ui text-wedding-body leading-wedding-body">{dictionary.invalid.description}</p></section></main>
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
return <WeddingInvite action={submitRsvpAction.bind(null, token)} detail={detail} token={token} dictionary={dictionary} content={getInvitePageContent(language)} closed={isRsvpClosed()} toastRequest={interactions.toastRequests[0]} filename={photoUpload?.original_filename ?? null} submitted={submitted} error={error} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default function InviteLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return <div className="wedding-theme min-h-full">{children}</div>
|
||||
return <div className="wedding-theme min-h-full scroll-smooth">{children}</div>
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section id="details" className="bg-wedding-ivory">
|
||||
<WeddingContainer className="py-wedding-section">
|
||||
<div className="grid gap-10 wedding-layout:grid-cols-2 wedding-layout:items-center">
|
||||
<SectionHeading eyebrow={content.eyebrow} heading={content.heading} description={content.introduction} />
|
||||
<div className="relative aspect-video overflow-hidden"><Image src="/images/details_main.png" alt="Luttelforster Muhle venue" fill className="object-cover" sizes="(min-width: 900px) 55vw, 90vw" /></div>
|
||||
</div>
|
||||
<div className="mt-14 grid divide-y divide-wedding-line border-y border-wedding-line wedding-phone:grid-cols-3 wedding-phone:divide-x wedding-phone:divide-y-0 wedding-phone:border-y-0">
|
||||
{content.facts.map((fact, index) => <article className="py-7 wedding-phone:px-6 wedding-phone:py-0" key={fact.label}><p className="wedding-section-label">0{index + 1} · {fact.label}</p><h3 className="mt-3 font-wedding-display text-wedding-medium">{fact.value}</h3><p className="mt-2 font-wedding-ui text-sm leading-wedding-body">{fact.description}</p></article>)}
|
||||
</div>
|
||||
<div className="mt-16 grid gap-8 wedding-phone:grid-cols-2">
|
||||
{content.expectations.map((item, index) => <article key={item.label}><div className="relative aspect-video overflow-hidden"><Image src={expectationImages[index]} alt={item.label} fill className="object-cover" sizes="(min-width: 600px) 45vw, 90vw" /></div><h3 className="mt-5 font-wedding-display text-wedding-medium">{item.label}</h3><p className="mt-2 font-wedding-ui text-wedding-body">{item.value}</p><p className="mt-1 font-wedding-ui text-sm">{item.description}</p></article>)}
|
||||
</div>
|
||||
<div className="mt-16 bg-wedding-cream p-8 wedding-phone:p-12"><p className="wedding-section-label">{content.dressCode.label}</p><h3 className="mt-4 font-wedding-display text-wedding-section">{content.dressCode.value}</h3><p className="mt-4 max-w-xl font-wedding-ui text-wedding-body leading-wedding-body">{content.dressCode.description}</p></div>
|
||||
<div className="mt-16 grid gap-8 wedding-phone:grid-cols-2 wedding-layout:grid-cols-5">
|
||||
{content.policies.map((policy) => <article className="border-l border-wedding-line pl-5" key={policy.title}><h3 className="font-wedding-display text-wedding-medium">{policy.title}</h3><p className="mt-3 font-wedding-ui text-sm leading-wedding-body">{policy.description}</p></article>)}
|
||||
</div>
|
||||
</WeddingContainer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<section id="faq" className="bg-wedding-ivory py-wedding-section">
|
||||
<WeddingContainer>
|
||||
<SectionHeading eyebrow={content.eyebrow} heading={content.heading} description={content.introduction} />
|
||||
<div className="mt-14 grid gap-x-12 wedding-layout:grid-cols-2">
|
||||
{content.questions.map((item) => <details className="group border-b border-wedding-line py-6" key={item.question}><summary className="flex cursor-pointer list-none items-start justify-between gap-6 font-wedding-display text-wedding-medium"><span>{item.question}</span><span className="font-wedding-ui text-wedding-label text-wedding-terracotta transition-transform group-open:rotate-45">+</span></summary><p className="mt-4 max-w-xl font-wedding-ui text-wedding-body leading-wedding-body">{item.answer}</p></details>)}
|
||||
</div>
|
||||
<aside className="mt-16 flex flex-col gap-5 bg-wedding-cream p-8 wedding-phone:flex-row wedding-phone:items-center wedding-phone:justify-between wedding-phone:p-12">
|
||||
<div><h3 className="font-wedding-display text-wedding-medium">{content.contact.heading}</h3><p className="mt-2 font-wedding-ui text-wedding-body">{content.contact.description}</p></div>
|
||||
<Link className="wedding-text-link" href="mailto:hello@example.com">{content.contact.action} →</Link>
|
||||
</aside>
|
||||
</WeddingContainer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid wedding-layout:grid-cols-2">
|
||||
<div
|
||||
className={`flex items-center bg-wedding-ivory px-wedding-gutter-mobile py-wedding-section wedding-phone:px-wedding-gutter-phone wedding-layout:px-wedding-gutter-desktop ${reverse ? "wedding-layout:order-2" : ""}`}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-md">{children}</div>
|
||||
</div>
|
||||
<div className={`relative min-h-96 ${reverse ? "wedding-layout:order-1" : ""}`}>
|
||||
<Image className="object-cover" src={image} alt={alt} fill sizes="(min-width: 900px) 60vw, 100vw" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function HeartQuote({ quote }: { quote: string }) {
|
||||
return (
|
||||
<blockquote className="bg-wedding-cream px-wedding-gutter-mobile py-12 text-center wedding-phone:px-wedding-gutter-phone">
|
||||
<p className="font-wedding-display text-wedding-medium italic leading-wedding-display text-wedding-terracotta">
|
||||
“{quote}”
|
||||
</p>
|
||||
<div className="mx-auto mt-8 flex max-w-xs items-center gap-4 text-wedding-terracotta">
|
||||
<span className="h-px flex-1 bg-wedding-line" />
|
||||
<span aria-hidden="true">♥</span>
|
||||
<span className="h-px flex-1 bg-wedding-line" />
|
||||
</div>
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<section id="top" className="relative isolate flex min-h-screen items-end overflow-hidden bg-wedding-ink text-wedding-white">
|
||||
<Image className="object-cover wedding-layout:hidden" src="/images/hero_mobile.png" alt="Nicole and Raul" fill priority sizes="100vw" />
|
||||
<Image className="hidden object-cover wedding-layout:block" src="/images/hero_desktop.png" alt="Nicole and Raul" fill priority sizes="100vw" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-wedding-ink/75 via-wedding-ink/30 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-wedding-ink/65 to-transparent" />
|
||||
<SiteHeader />
|
||||
<div className="relative z-10 w-full px-wedding-gutter-mobile pb-16 wedding-phone:px-wedding-gutter-phone wedding-layout:px-wedding-gutter-desktop wedding-layout:pb-24">
|
||||
<p className="font-wedding-ui text-wedding-label font-medium uppercase tracking-wedding-label">{content.eyebrow}</p>
|
||||
<h1 className="wedding-display-heading mt-6 max-w-5xl text-wedding-hero leading-none text-wedding-white">
|
||||
{content.title}
|
||||
</h1>
|
||||
<p className="mt-5 font-wedding-display text-wedding-medium italic">{content.subtitle}</p>
|
||||
<Link className="mt-14 inline-flex flex-col items-center gap-2 font-wedding-ui text-wedding-label font-medium uppercase tracking-wedding-label" href="#introduction">
|
||||
Scroll <span aria-hidden="true">↓</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<section id="introduction" className="bg-wedding-ivory py-wedding-section">
|
||||
<WeddingContainer>
|
||||
<Monogram />
|
||||
<div className="mt-10 grid gap-10 wedding-layout:grid-cols-2 wedding-layout:items-center">
|
||||
<div>
|
||||
<h2 className="wedding-display-heading text-wedding-page text-wedding-ink">{content.heading}</h2>
|
||||
<div className="mt-7 w-12 border-t-2 border-wedding-terracotta" />
|
||||
{content.paragraphs.map((paragraph) => <p className="mt-6 font-wedding-ui text-wedding-body leading-wedding-body" key={paragraph}>{paragraph}</p>)}
|
||||
<p className="mt-7 font-wedding-display text-wedding-medium italic text-wedding-terracotta">Nicole & Raul</p>
|
||||
</div>
|
||||
<div className="relative mx-auto min-h-96 w-full max-w-md shadow-lg">
|
||||
<Image src="/images/schedule.png" alt="A candlelit wedding reception" fill className="object-cover" sizes="(min-width: 900px) 40vw, 90vw" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-14 grid border-y border-wedding-line wedding-phone:grid-cols-3 wedding-phone:border-y-0">
|
||||
{content.facts.map((fact, index) => {
|
||||
const Icon = icons[index]
|
||||
return <div className={`flex gap-4 px-0 py-7 wedding-phone:px-6 wedding-phone:py-0 ${index < content.facts.length - 1 ? "wedding-phone:border-r wedding-phone:border-wedding-line" : ""}`} key={fact.label}>
|
||||
<Icon className="mt-1 size-5 text-wedding-terracotta" aria-hidden="true" />
|
||||
<div><p className="wedding-section-label">{fact.label}</p><p className="mt-2 font-wedding-display text-wedding-medium">{fact.value}</p>{fact.description ? <p className="mt-1 font-wedding-ui text-sm">{fact.description}</p> : null}</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</WeddingContainer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Monogram({ light = false, className }: { light?: boolean; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-wedding-display text-3xl tracking-tight",
|
||||
light ? "text-wedding-white" : "text-wedding-terracotta",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
N <span className="px-1 font-wedding-ui text-xs">|</span> R
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -99,40 +99,43 @@ export function PhotoUpload({
|
||||
}
|
||||
|
||||
return (
|
||||
<section 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">{labels.title}</h2>
|
||||
<p className="mt-2 text-stone-600">{labels.description}</p>
|
||||
<p className="mt-2 text-sm text-stone-500">{labels.requirements}</p>
|
||||
<section className="border-t border-wedding-line pt-10">
|
||||
<div className="grid gap-4 wedding-layout:grid-cols-3">
|
||||
<p className="wedding-section-label">04</p>
|
||||
<div>
|
||||
<h2 className="font-wedding-display text-wedding-medium">{labels.title}</h2>
|
||||
<p className="mt-2 font-wedding-ui text-sm leading-wedding-body">{labels.description}</p>
|
||||
</div>
|
||||
<p className="font-wedding-ui text-sm">{labels.requirements}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="mt-6 wedding-layout:ml-60">
|
||||
{filename ? (
|
||||
<p className="rounded-2xl bg-stone-100 px-4 py-3 text-sm text-stone-700">
|
||||
<p className="bg-wedding-cream px-4 py-3 font-wedding-ui text-sm text-wedding-ink">
|
||||
{labels.current.replace("{filename}", filename)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{disabled ? (
|
||||
<p className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<p className="border border-wedding-line bg-wedding-cream px-4 py-3 font-wedding-ui text-sm">
|
||||
{labels.disabled}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<label className="flex-1 space-y-2">
|
||||
<span className="text-sm font-medium">{labels.choose}</span>
|
||||
<div className="mt-4 flex flex-col gap-4 wedding-phone:flex-row wedding-phone:items-end">
|
||||
<label className="flex-1 font-wedding-ui text-sm">
|
||||
<span>{labels.choose}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={allowedImageContentTypes.join(",")}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
className="mt-2 w-full border border-wedding-line bg-wedding-ivory p-3"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadSelectedFile}
|
||||
disabled={status === "uploading"}
|
||||
className="rounded-full bg-stone-950 px-6 py-3 font-medium text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:opacity-60 sm:self-end"
|
||||
className="wedding-primary-cta disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{status === "uploading"
|
||||
? labels.uploading
|
||||
@@ -144,9 +147,9 @@ export function PhotoUpload({
|
||||
)}
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="text-sm text-emerald-700">{labels.success}</p>
|
||||
<p className="mt-4 font-wedding-ui text-sm text-wedding-olive">{labels.success}</p>
|
||||
) : null}
|
||||
{status === "error" ? <p className="text-sm text-red-700">{labels.error}</p> : null}
|
||||
{status === "error" ? <p className="mt-4 font-wedding-ui text-sm text-wedding-terracotta">{labels.error}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { InvitationDetail } from "@/lib/db/queries/invitations"
|
||||
import type { ToastRequest } from "@/lib/db/types"
|
||||
import type { GuestDictionary } from "@/lib/i18n/dictionaries"
|
||||
|
||||
import { PhotoUpload } from "@/components/invite/photo-upload"
|
||||
import { RsvpGuestFields } from "@/components/invite/rsvp-guest-fields"
|
||||
import { RsvpToastFields } from "@/components/invite/rsvp-toast-fields"
|
||||
import { RsvpTravelFields } from "@/components/invite/rsvp-travel-fields"
|
||||
|
||||
export function RsvpForm({
|
||||
action,
|
||||
detail,
|
||||
token,
|
||||
dictionary,
|
||||
closed,
|
||||
toastRequest,
|
||||
filename,
|
||||
}: {
|
||||
action: (formData: FormData) => void | Promise<void>
|
||||
detail: InvitationDetail
|
||||
token: string
|
||||
dictionary: GuestDictionary
|
||||
closed: boolean
|
||||
toastRequest: ToastRequest | undefined
|
||||
filename: string | null
|
||||
}) {
|
||||
return (
|
||||
<form action={action} className="mt-12">
|
||||
<RsvpGuestFields members={detail.members} dictionary={dictionary} disabled={closed} />
|
||||
<RsvpTravelFields response={detail.response} dictionary={dictionary} disabled={closed} />
|
||||
<RsvpToastFields members={detail.members} toastRequest={toastRequest} dictionary={dictionary} disabled={closed} />
|
||||
<div className="py-10"><PhotoUpload token={token} labels={dictionary.upload} disabled={closed} existingFilename={filename} /></div>
|
||||
{!closed ? <button type="submit" className="wedding-primary-cta w-full">{dictionary.form.submit} →</button> : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InvitationMember } from "@/lib/db/types"
|
||||
import type { GuestDictionary } from "@/lib/i18n/dictionaries"
|
||||
import type { MealPreference } from "@/lib/validation/response"
|
||||
|
||||
const mealOptions: MealPreference[] = ["meat", "fish", "vegetarian", "vegan", "kids", "other"]
|
||||
|
||||
export function RsvpGuestFields({
|
||||
members,
|
||||
dictionary,
|
||||
disabled,
|
||||
}: {
|
||||
members: InvitationMember[]
|
||||
dictionary: GuestDictionary
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<section className="border-t border-wedding-line pt-10">
|
||||
{members.map((member, index) => (
|
||||
<fieldset className="grid gap-6 border-b border-wedding-line py-10 wedding-layout:grid-cols-3" disabled={disabled} key={member.id}>
|
||||
<legend className="sr-only">{member.name}</legend>
|
||||
<p className="wedding-section-label">0{index + 1}</p>
|
||||
<div><h3 className="font-wedding-display text-wedding-medium">{member.name}</h3><p className="mt-2 font-wedding-ui text-sm">{dictionary.form.attending}</p></div>
|
||||
<div className="grid gap-6 wedding-phone:grid-cols-2">
|
||||
<div className="flex gap-3"><label className="flex flex-1 cursor-pointer items-center justify-center border border-wedding-line p-3 font-wedding-display text-lg"><input className="sr-only" type="radio" name={`attending-${member.id}`} value="yes" defaultChecked={member.attending === true} required />{dictionary.form.yes}</label><label className="flex flex-1 cursor-pointer items-center justify-center border border-wedding-line p-3 font-wedding-display text-lg"><input className="sr-only" type="radio" name={`attending-${member.id}`} value="no" defaultChecked={member.attending === false} required />{dictionary.form.no}</label></div>
|
||||
<label className="font-wedding-ui text-sm"><span>{dictionary.form.mealPreference}</span><select name={`meal-${member.id}`} defaultValue={member.meal_preference ?? ""} className="mt-2 w-full border border-wedding-line bg-wedding-ivory p-3"><option value="">{dictionary.form.mealPlaceholder}</option>{mealOptions.map((meal) => <option key={meal} value={meal}>{dictionary.meals[meal]}</option>)}</select></label>
|
||||
<label className="font-wedding-ui text-sm wedding-phone:col-span-2"><span>{dictionary.form.dietaryRestrictions}</span><input name={`dietary-${member.id}`} defaultValue={member.dietary_restrictions ?? ""} placeholder={dictionary.form.noRestrictions} className="mt-2 w-full border-b border-wedding-line bg-transparent p-3" /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { RsvpForm } from "@/components/invite/rsvp-form"
|
||||
import { WeddingContainer } from "@/components/invite/wedding-container"
|
||||
import type { InvitationDetail } from "@/lib/db/queries/invitations"
|
||||
import type { ToastRequest } from "@/lib/db/types"
|
||||
import type { GuestDictionary } from "@/lib/i18n/dictionaries"
|
||||
import type { InvitePageContent } from "@/lib/i18n/invite-page/content"
|
||||
|
||||
export function RsvpSection(props: {
|
||||
action: (formData: FormData) => void | Promise<void>
|
||||
detail: InvitationDetail
|
||||
token: string
|
||||
dictionary: GuestDictionary
|
||||
content: InvitePageContent["rsvp"]
|
||||
closed: boolean
|
||||
toastRequest: ToastRequest | undefined
|
||||
filename: string | null
|
||||
submitted?: string
|
||||
error?: string
|
||||
}) {
|
||||
const message = props.submitted ? props.dictionary.form.submitted : props.error === "closed" ? props.dictionary.errors.closed : props.error ? props.dictionary.errors.generic : null
|
||||
return (
|
||||
<section id="rsvp" className="bg-wedding-ivory py-wedding-section">
|
||||
<WeddingContainer wide>
|
||||
<div className="grid gap-14 wedding-layout:grid-cols-2">
|
||||
<div><p className="wedding-section-label">{props.content.eyebrow}</p><h2 className="wedding-display-heading mt-5 text-wedding-page">{props.content.heading}</h2><div className="mt-7 w-12 border-t-2 border-wedding-terracotta" /><p className="mt-7 font-wedding-ui text-wedding-body leading-wedding-body">{props.content.paragraph} <strong>{props.content.deadline}</strong>.</p><p className="mt-4 font-wedding-display text-wedding-medium italic text-wedding-terracotta">{props.content.helper}</p><Link className="mt-12 inline-block wedding-text-link" href="#faq">{props.content.faqLink} →</Link></div>
|
||||
<div>{message ? <p aria-live="polite" className="border border-wedding-line bg-wedding-cream p-5 font-wedding-ui text-sm">{message}</p> : null}<RsvpForm {...props} /></div>
|
||||
</div>
|
||||
</WeddingContainer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import type { InvitationMember, ToastRequest } from "@/lib/db/types"
|
||||
import type { GuestDictionary } from "@/lib/i18n/dictionaries"
|
||||
|
||||
export function RsvpToastFields({
|
||||
members,
|
||||
toastRequest,
|
||||
dictionary,
|
||||
disabled,
|
||||
}: {
|
||||
members: InvitationMember[]
|
||||
toastRequest: ToastRequest | undefined
|
||||
dictionary: GuestDictionary
|
||||
disabled: boolean
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(Boolean(toastRequest))
|
||||
|
||||
return (
|
||||
<fieldset className="grid gap-6 border-b border-wedding-line py-10 wedding-layout:grid-cols-3" disabled={disabled}>
|
||||
<legend className="sr-only">{dictionary.interactions.toastTitle}</legend>
|
||||
<p className="wedding-section-label">03</p>
|
||||
<div><h3 className="font-wedding-display text-wedding-medium">{dictionary.interactions.toastTitle}</h3><p className="mt-2 font-wedding-ui text-sm leading-wedding-body">{dictionary.interactions.toastDescription}</p></div>
|
||||
<div>
|
||||
<label className="flex items-center gap-3 font-wedding-ui text-sm"><input type="checkbox" name="wantsToast" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />{dictionary.interactions.wantsToast}</label>
|
||||
<div className="mt-6 grid gap-6 wedding-phone:grid-cols-2">
|
||||
<label className="font-wedding-ui text-sm"><span>{dictionary.interactions.speakerName}</span><input name="toastSpeakerName" disabled={!enabled} defaultValue={toastRequest?.speaker_name ?? ""} className="mt-2 w-full border-b border-wedding-line bg-transparent p-3 disabled:opacity-50" /></label>
|
||||
<label className="font-wedding-ui text-sm"><span>{dictionary.interactions.estimatedMinutes}</span><input name="toastEstimatedMinutes" type="number" min="1" disabled={!enabled} defaultValue={toastRequest?.estimated_minutes ?? ""} className="mt-2 w-full border-b border-wedding-line bg-transparent p-3 disabled:opacity-50" /></label>
|
||||
</div>
|
||||
<select name="toastMemberId" defaultValue={toastRequest?.member_id ?? ""} className="sr-only"><option value="" />{members.map((member) => <option key={member.id} value={member.id}>{member.name}</option>)}</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InvitationResponse } from "@/lib/db/types"
|
||||
import type { GuestDictionary } from "@/lib/i18n/dictionaries"
|
||||
|
||||
function dateValue(value: Date | string | null | undefined) {
|
||||
return value instanceof Date ? value.toISOString().slice(0, 10) : value ?? ""
|
||||
}
|
||||
|
||||
export function RsvpTravelFields({
|
||||
response,
|
||||
dictionary,
|
||||
disabled,
|
||||
}: {
|
||||
response: InvitationResponse | null
|
||||
dictionary: GuestDictionary
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="grid gap-6 border-b border-wedding-line py-10 wedding-layout:grid-cols-3" disabled={disabled}>
|
||||
<legend className="sr-only">{dictionary.sections.travel}</legend>
|
||||
<p className="wedding-section-label">02</p>
|
||||
<div><h3 className="font-wedding-display text-wedding-medium">{dictionary.sections.travel}</h3></div>
|
||||
<div className="grid gap-6 wedding-phone:grid-cols-2">
|
||||
<label className="font-wedding-ui text-sm"><span>{dictionary.form.arrivalDate}</span><input type="date" name="arrivalDate" defaultValue={dateValue(response?.arrival_date)} className="mt-2 w-full border-b border-wedding-line bg-transparent p-3" /></label>
|
||||
<label className="font-wedding-ui text-sm"><span>{dictionary.form.departureDate}</span><input type="date" name="departureDate" defaultValue={dateValue(response?.departure_date)} className="mt-2 w-full border-b border-wedding-line bg-transparent p-3" /></label>
|
||||
<label className="font-wedding-ui text-sm wedding-phone:col-span-2"><span>{dictionary.form.stayLocation}</span><input name="stayLocation" defaultValue={response?.stay_location ?? ""} className="mt-2 w-full border-b border-wedding-line bg-transparent p-3" /></label>
|
||||
<div className="wedding-phone:col-span-2"><p className="font-wedding-ui text-sm">{dictionary.form.needsTransportHelp}</p><div className="mt-3 flex gap-6 font-wedding-ui text-sm"><label><input type="radio" name="needsTransportHelp" value="yes" defaultChecked={response?.needs_transport_help === true} /> {dictionary.form.yes}</label><label><input type="radio" name="needsTransportHelp" value="no" defaultChecked={response?.needs_transport_help === false} /> {dictionary.form.no}</label></div></div>
|
||||
<label className="font-wedding-ui text-sm wedding-phone:col-span-2"><span>{dictionary.form.messageToCouple}</span><textarea name="messageToCouple" defaultValue={response?.message_to_couple ?? ""} rows={4} className="mt-2 w-full border border-wedding-line bg-transparent p-3" /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FeatureSplit } from "@/components/invite/feature-split"
|
||||
import { HeartQuote } from "@/components/invite/heart-quote"
|
||||
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 ScheduleSection({ content }: { content: InvitePageContent["schedule"] }) {
|
||||
return (
|
||||
<section id="schedule" className="bg-wedding-ivory">
|
||||
<FeatureSplit image="/images/schedule.png" alt="Wedding dinner celebration" reverse>
|
||||
<SectionHeading eyebrow={content.eyebrow} heading={content.heading} description={content.introduction} />
|
||||
<p className="mt-7 font-wedding-display text-wedding-medium italic text-wedding-terracotta">Nicole & Raul</p>
|
||||
</FeatureSplit>
|
||||
<WeddingContainer className="py-wedding-section">
|
||||
<p className="wedding-section-label">{content.label}</p>
|
||||
<div className="mt-10 grid gap-8 wedding-phone:grid-cols-2 wedding-layout:grid-cols-6">
|
||||
{content.timeline.map((item) => <article className="border-l border-wedding-line pl-5 wedding-layout:border-l-0 wedding-layout:border-t wedding-layout:pt-6 wedding-layout:pl-0" key={item.time}><span className="-ml-7 block size-3 rounded-full bg-wedding-terracotta wedding-layout:-mt-8 wedding-layout:ml-0" /><p className="mt-4 wedding-section-label">{item.time}</p><h3 className="mt-3 font-wedding-display text-wedding-medium">{item.title}</h3><p className="mt-4 font-wedding-ui text-sm leading-wedding-body">{item.description}</p></article>)}
|
||||
</div>
|
||||
</WeddingContainer>
|
||||
<HeartQuote quote={content.quote} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function SectionHeading({
|
||||
eyebrow,
|
||||
heading,
|
||||
description,
|
||||
align = "left",
|
||||
}: {
|
||||
eyebrow: string
|
||||
heading: string
|
||||
description?: string
|
||||
align?: "left" | "center"
|
||||
}) {
|
||||
const alignment = align === "center" ? "items-center text-center" : "items-start"
|
||||
|
||||
return (
|
||||
<div className={`flex max-w-2xl flex-col ${alignment}`}>
|
||||
<p className="wedding-section-label">{eyebrow}</p>
|
||||
<h2 className="wedding-display-heading mt-5 text-wedding-section text-wedding-ink">
|
||||
{heading}
|
||||
</h2>
|
||||
<div className="mt-7 w-12 border-t-2 border-wedding-terracotta" />
|
||||
{description ? (
|
||||
<p className="mt-7 font-wedding-ui text-wedding-body leading-wedding-body text-wedding-ink">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { Monogram } from "@/components/invite/monogram"
|
||||
|
||||
const links = [
|
||||
["Introduction", "#introduction"],
|
||||
["Our story", "#story"],
|
||||
["Details", "#details"],
|
||||
["Schedule", "#schedule"],
|
||||
["FAQ", "#faq"],
|
||||
["RSVP", "#rsvp"],
|
||||
] as const
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="absolute inset-x-0 top-0 z-20 px-wedding-gutter-mobile py-6 wedding-phone:px-wedding-gutter-phone wedding-layout:px-wedding-gutter-desktop">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="#top" aria-label="Back to top">
|
||||
<Monogram light />
|
||||
</Link>
|
||||
<nav aria-label="Invitation sections" className="hidden wedding-layout:block">
|
||||
<ul className="flex items-center gap-6">
|
||||
{links.map(([label, href]) => (
|
||||
<li key={href}>
|
||||
<Link className="font-wedding-ui text-wedding-label font-medium uppercase tracking-wedding-label text-wedding-white" href={href}>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<details className="group relative wedding-layout:hidden">
|
||||
<summary className="cursor-pointer list-none font-wedding-ui text-wedding-label font-medium uppercase tracking-wedding-label text-wedding-white">
|
||||
Menu <span aria-hidden="true">☰</span>
|
||||
</summary>
|
||||
<nav aria-label="Invitation sections" className="absolute right-0 top-8 w-48 bg-wedding-ivory p-5 shadow-lg">
|
||||
<ul className="space-y-4">
|
||||
{links.map(([label, href]) => (
|
||||
<li key={href}>
|
||||
<Link className="wedding-text-link" href={href}>{label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Image from "next/image"
|
||||
|
||||
import { FeatureSplit } from "@/components/invite/feature-split"
|
||||
import { HeartQuote } from "@/components/invite/heart-quote"
|
||||
import { SectionHeading } from "@/components/invite/section-heading"
|
||||
import { WeddingContainer } from "@/components/invite/wedding-container"
|
||||
import type { InvitePageContent } from "@/lib/i18n/invite-page/content"
|
||||
|
||||
const journeyImages = [
|
||||
"/images/story_journey_1.png",
|
||||
"/images/story_journey_2.png",
|
||||
"/images/story_journey_3.png",
|
||||
]
|
||||
|
||||
export function StorySection({ content }: { content: InvitePageContent["story"] }) {
|
||||
return (
|
||||
<section id="story" className="bg-wedding-ivory">
|
||||
<FeatureSplit image="/images/our_story.png" alt="Nicole, Raul and their dog in the countryside">
|
||||
<SectionHeading eyebrow={content.eyebrow} heading={content.heading} />
|
||||
{content.paragraphs.map((paragraph) => (
|
||||
<p className="mt-6 font-wedding-ui text-wedding-body leading-wedding-body text-wedding-ink" key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
<p className="mt-7 font-wedding-display text-wedding-medium italic text-wedding-terracotta">Nicole & Raul</p>
|
||||
</FeatureSplit>
|
||||
<WeddingContainer className="py-wedding-section">
|
||||
<p className="wedding-section-label">Our journey</p>
|
||||
<div className="mt-12 grid gap-10 wedding-layout:grid-cols-3">
|
||||
{content.timeline.map((item, index) => (
|
||||
<article className="border-l border-wedding-line pl-6 wedding-layout:border-l-0 wedding-layout:pl-0" key={item.year}>
|
||||
<div className="mb-5 flex items-center gap-4 wedding-layout:flex-col wedding-layout:items-start">
|
||||
<span className="size-3 rounded-full bg-wedding-terracotta" />
|
||||
<p className="wedding-section-label">{item.year}</p>
|
||||
</div>
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
<Image src={journeyImages[index]} alt={item.title} fill className="object-cover" sizes="(min-width: 900px) 30vw, 85vw" />
|
||||
</div>
|
||||
<h3 className="mt-5 font-wedding-display text-wedding-medium text-wedding-ink">{item.title}</h3>
|
||||
{item.place ? <p className="mt-1 wedding-section-label">{item.place}</p> : null}
|
||||
<p className="mt-4 font-wedding-ui text-sm leading-wedding-body text-wedding-ink">{item.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</WeddingContainer>
|
||||
<HeartQuote quote={content.quote} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function WeddingContainer({
|
||||
children,
|
||||
wide = false,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
wide?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full px-wedding-gutter-mobile wedding-phone:px-wedding-gutter-phone wedding-layout:px-wedding-gutter-desktop",
|
||||
wide ? "max-w-wedding-content" : "max-w-6xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DetailsSection } from "@/components/invite/details-section"
|
||||
import { FaqSection } from "@/components/invite/faq-section"
|
||||
import { HeroSection } from "@/components/invite/hero-section"
|
||||
import { IntroductionSection } from "@/components/invite/introduction-section"
|
||||
import { RsvpSection } from "@/components/invite/rsvp-section"
|
||||
import { ScheduleSection } from "@/components/invite/schedule-section"
|
||||
import { StorySection } from "@/components/invite/story-section"
|
||||
import type { InvitationDetail } from "@/lib/db/queries/invitations"
|
||||
import type { ToastRequest } from "@/lib/db/types"
|
||||
import type { GuestDictionary } from "@/lib/i18n/dictionaries"
|
||||
import type { InvitePageContent } from "@/lib/i18n/invite-page/content"
|
||||
|
||||
export function WeddingInvite({
|
||||
action,
|
||||
detail,
|
||||
token,
|
||||
dictionary,
|
||||
content,
|
||||
closed,
|
||||
toastRequest,
|
||||
filename,
|
||||
submitted,
|
||||
error,
|
||||
}: {
|
||||
action: (formData: FormData) => void | Promise<void>
|
||||
detail: InvitationDetail
|
||||
token: string
|
||||
dictionary: GuestDictionary
|
||||
content: InvitePageContent
|
||||
closed: boolean
|
||||
toastRequest: ToastRequest | undefined
|
||||
filename: string | null
|
||||
submitted?: string
|
||||
error?: string
|
||||
}) {
|
||||
return (
|
||||
<main className="overflow-x-hidden bg-wedding-ivory text-wedding-ink">
|
||||
<HeroSection content={content.hero} />
|
||||
<IntroductionSection content={content.introduction} />
|
||||
<StorySection content={content.story} />
|
||||
<DetailsSection content={content.details} />
|
||||
<ScheduleSection content={content.schedule} />
|
||||
<FaqSection content={content.faq} />
|
||||
<RsvpSection action={action} detail={detail} token={token} dictionary={dictionary} content={content.rsvp} closed={closed} toastRequest={toastRequest} filename={filename} submitted={submitted} error={error} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -10,4 +10,4 @@ export const dictionaries = {
|
||||
pl,
|
||||
} as const
|
||||
|
||||
export type GuestDictionary = typeof en
|
||||
export type GuestDictionary = (typeof dictionaries)[keyof typeof dictionaries]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SupportedLanguage } from "@/lib/i18n/config";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import { es } from "./es";
|
||||
import { pl } from "./pl";
|
||||
import type { InvitePageContent } from "./types";
|
||||
|
||||
export type { InvitePageContent } from "./types";
|
||||
|
||||
const content: Record<SupportedLanguage, InvitePageContent> = {
|
||||
en,
|
||||
es,
|
||||
de,
|
||||
pl,
|
||||
};
|
||||
|
||||
export function getInvitePageContent(language: SupportedLanguage): InvitePageContent {
|
||||
return content[language];
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { InvitePageContent } from "./types";
|
||||
|
||||
const venue = "Luttelforster Muhle", location = "Schwalmtal, Deutschland";
|
||||
|
||||
export const de: InvitePageContent = {
|
||||
hero: {
|
||||
eyebrow: "10. JULI 2027 · SCHWALMTAL",
|
||||
title: "Nicole und Raul",
|
||||
subtitle: "Wir heiraten.",
|
||||
date: "10. Juli 2027",
|
||||
location,
|
||||
},
|
||||
introduction: {
|
||||
heading: "Kommt wegen der Liebe. Bleibt wegen der Feier.",
|
||||
paragraphs: [
|
||||
"Wir freuen uns darauf, diesen besonderen Tag mit euch zu feiern: ein Nachmittag im Garten, ein langes Dinner unter Lichtern und eine unvergessliche Nacht.",
|
||||
"Danke, dass ihr Teil unserer Geschichte seid.",
|
||||
],
|
||||
facts: [
|
||||
{ label: "Datum", value: "10. Juli 2027", description: "Samstag" },
|
||||
{ label: "Ort", value: venue, description: location },
|
||||
{ label: "Trauung", value: "16:00 Uhr" },
|
||||
],
|
||||
},
|
||||
story: {
|
||||
eyebrow: "UNSERE GESCHICHTE",
|
||||
heading: "Der Anfang unseres Fuer Immer.",
|
||||
paragraphs: [
|
||||
"Verschiedene Orte, verschiedene Wege, bis das Leben uns zusammenbrachte. Aus einem Funken wurde etwas Echtes.",
|
||||
"Wir reisen, lachen, tanzen und traeumen. Jetzt feiern wir dieses neue Kapitel mit euch.",
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
year: "2018",
|
||||
title: "Wo alles begann",
|
||||
place: "Madrid",
|
||||
description: "Eine zufaellige Begegnung auf einer Staedtereise veraenderte alles.",
|
||||
},
|
||||
{
|
||||
year: "2021",
|
||||
title: "Irgendwo unterwegs",
|
||||
description: "Von spontanen Reisen zu unseren Lieblingsorten. Deutschland wurde unser Zuhause.",
|
||||
},
|
||||
{
|
||||
year: "2027",
|
||||
title: "Das naechste Kapitel",
|
||||
description: "Wir heiraten und freuen uns, mit euch zu feiern.",
|
||||
},
|
||||
],
|
||||
quote: "Verschiedene Orte, eine Reise. Unzaehlige Erinnerungen. Fuer immer wir.",
|
||||
},
|
||||
details: {
|
||||
eyebrow: "HOCHZEITSDETAILS",
|
||||
heading: "Alle wichtigen Details.",
|
||||
introduction: "Hier findet ihr alles Wissenswerte fuer unseren grossen Tag.",
|
||||
facts: [
|
||||
{
|
||||
label: "Wann",
|
||||
value: "10. Juli 2027",
|
||||
description: "Trauung um 16:00 Uhr",
|
||||
},
|
||||
{ label: "Wo", value: venue, description: location },
|
||||
{
|
||||
label: "Ankunft",
|
||||
value: "Ab 15:30 Uhr",
|
||||
description: "Bitte plant genug Zeit ein.",
|
||||
},
|
||||
],
|
||||
expectations: [
|
||||
{
|
||||
label: "Trauung",
|
||||
value: "Im Garten",
|
||||
description: "Bei passendem Wetter.",
|
||||
},
|
||||
{
|
||||
label: "Dinner und Party",
|
||||
value: "Drinks und Tanz",
|
||||
description: "Die Party geht drinnen weiter.",
|
||||
},
|
||||
],
|
||||
dressCode: {
|
||||
label: "Dresscode",
|
||||
value: "Sommerlich elegant",
|
||||
description: "Leichte Stoffe, sanfte Farben. Bitte kein Schwarz.",
|
||||
},
|
||||
policies: [
|
||||
{ title: "Kinder", description: "Die Feier ist nur fuer Erwachsene." },
|
||||
{ title: "Hunde", description: "Bitte lasst Hunde zu Hause." },
|
||||
{
|
||||
title: "Wetter",
|
||||
description: "Bei Regen gibt es einen schoenen Plan B.",
|
||||
},
|
||||
{ title: "Parken", description: "Parkplaetze gibt es vor Ort." },
|
||||
{
|
||||
title: "Barrierefreiheit",
|
||||
description: "Die Location ist weitgehend barrierefrei.",
|
||||
},
|
||||
],
|
||||
},
|
||||
schedule: {
|
||||
eyebrow: "ABLAUF",
|
||||
heading: "So feiern wir.",
|
||||
introduction: "Ein Tag voller Liebe, Lachen und unvergesslicher Momente.",
|
||||
label: "Der Tag auf einen Blick",
|
||||
timeline: [
|
||||
{
|
||||
time: "15:15",
|
||||
title: "Ankunft",
|
||||
description: "Ankommen, Platz nehmen und bereit sein.",
|
||||
},
|
||||
{
|
||||
time: "16:00",
|
||||
title: "Trauung",
|
||||
description: "Wir sagen Ja im Garten.",
|
||||
},
|
||||
{
|
||||
time: "16:45",
|
||||
title: "Cocktails und Happen",
|
||||
description: "Anstossen und gute Gesellschaft.",
|
||||
},
|
||||
{
|
||||
time: "18:30",
|
||||
title: "Dinner",
|
||||
description: "Essen, reden, lachen und feiern.",
|
||||
},
|
||||
{
|
||||
time: "21:00",
|
||||
title: "Dessert",
|
||||
description: "Etwas Susses vor der lauten Musik.",
|
||||
},
|
||||
{
|
||||
time: "22:00-spaet",
|
||||
title: "Tanzen",
|
||||
description: "Unter den Sternen, so lange wir wollen.",
|
||||
},
|
||||
],
|
||||
quote: "Manche Momente werden Erinnerungen, und dieser wird fuer immer.",
|
||||
},
|
||||
faq: {
|
||||
eyebrow: "FAQ",
|
||||
heading: "Fragen? Wir helfen.",
|
||||
introduction: "Antworten auf alles, was ihr vielleicht wissen moechtet.",
|
||||
questions: [
|
||||
{
|
||||
question: "Darf ich eine Begleitung mitbringen?",
|
||||
answer: "Bitte bringt nur die Person mit, die auf der Einladung steht.",
|
||||
},
|
||||
{
|
||||
question: "Sind Kinder eingeladen?",
|
||||
answer: "Die Feier ist nur fuer Erwachsene.",
|
||||
},
|
||||
{
|
||||
question: "Darf ich meinen Hund mitbringen?",
|
||||
answer: "Bitte lasst Hunde zu Hause.",
|
||||
},
|
||||
{
|
||||
question: "Gibt es Parkplaetze?",
|
||||
answer: "Ja, Parkplaetze gibt es vor Ort.",
|
||||
},
|
||||
{
|
||||
question: "Gibt es einen Shuttle?",
|
||||
answer: "Bitte plant eure An- und Abreise selbst.",
|
||||
},
|
||||
{
|
||||
question: "Was passiert bei Regen?",
|
||||
answer: "Es gibt einen schoenen Plan B drinnen.",
|
||||
},
|
||||
{
|
||||
question: "Was ist der Dresscode?",
|
||||
answer: "Sommerlich elegant, bitte kein Schwarz.",
|
||||
},
|
||||
{
|
||||
question: "Geht die Feier nach drinnen?",
|
||||
answer: "Spaeter geht die Party drinnen weiter.",
|
||||
},
|
||||
{
|
||||
question: "Was wird serviert?",
|
||||
answer: "Ein saisonales Dinner; nennt uns Allergien vorher.",
|
||||
},
|
||||
{
|
||||
question: "Kann ich eine Rede halten?",
|
||||
answer: "Stimmt Ueberraschungen mit unserer Hochzeitsgesellschaft ab.",
|
||||
},
|
||||
],
|
||||
contact: {
|
||||
heading: "Noch eine Frage?",
|
||||
description: "Wir helfen gern.",
|
||||
action: "Kontakt aufnehmen",
|
||||
},
|
||||
},
|
||||
rsvp: {
|
||||
eyebrow: "RSVP",
|
||||
heading: "Wir freuen uns, mit euch zu feiern.",
|
||||
paragraph: "Bitte gebt uns bis",
|
||||
deadline: "15. Mai 2027",
|
||||
helper: "Wir hoffen, ihr seid dabei!",
|
||||
faqLink: "Fragen? Zu den FAQ",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { InvitePageContent } from "./types";
|
||||
|
||||
const event = { date: "10 July 2027", venue: "Luttelforster Muhle", location: "Schwalmtal, Germany" };
|
||||
|
||||
export const en: InvitePageContent = {
|
||||
hero: {
|
||||
eyebrow: "10 JULY 2027 · SCHWALMTAL",
|
||||
title: "Nicole & Raul",
|
||||
subtitle: "We're getting married.",
|
||||
date: event.date,
|
||||
location: event.location,
|
||||
},
|
||||
introduction: {
|
||||
heading: "Come for the love. Stay for the party.",
|
||||
paragraphs: [
|
||||
"We can't wait to celebrate this special day with you. An afternoon in the garden, a long dinner under the lights and a night we'll never forget.",
|
||||
"Thank you for being part of our story.",
|
||||
],
|
||||
facts: [
|
||||
{ label: "Date", value: event.date, description: "Saturday" },
|
||||
{ label: "Location", value: event.venue, description: event.location },
|
||||
{ label: "Ceremony", value: "16:00" },
|
||||
],
|
||||
},
|
||||
story: {
|
||||
eyebrow: "OUR STORY",
|
||||
heading: "The beginning of our forever.",
|
||||
paragraphs: [
|
||||
"Different places, different paths - until life brought us together. What started with a spark turned into something real.",
|
||||
"We travel, laugh, dance and dream. Now we can't wait to celebrate this next chapter with you.",
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
year: "2018",
|
||||
title: "Where it started",
|
||||
place: "Madrid",
|
||||
description: "A chance encounter during a city trip changed everything.",
|
||||
},
|
||||
{
|
||||
year: "2021",
|
||||
title: "Somewhere along the way",
|
||||
description: "From spontaneous trips to our favorite places. Germany became home.",
|
||||
},
|
||||
{
|
||||
year: "2027",
|
||||
title: "The next chapter",
|
||||
description: "We get married and can't wait to celebrate with you.",
|
||||
},
|
||||
],
|
||||
quote: "Different places, one journey. Countless memories. Forever us.",
|
||||
},
|
||||
details: {
|
||||
eyebrow: "WEDDING DETAILS",
|
||||
heading: "All the details that matter.",
|
||||
introduction: "Here is everything you need to know for our big day.",
|
||||
facts: [
|
||||
{
|
||||
label: "When",
|
||||
value: "July 10, 2027",
|
||||
description: "Ceremony starts at 16:00",
|
||||
},
|
||||
{ label: "Where", value: event.venue, description: event.location },
|
||||
{
|
||||
label: "Arrival",
|
||||
value: "From 15:30",
|
||||
description: "Please plan to arrive on time.",
|
||||
},
|
||||
],
|
||||
expectations: [
|
||||
{
|
||||
label: "Ceremony",
|
||||
value: "Outdoors in the garden",
|
||||
description: "Weather permitting.",
|
||||
},
|
||||
{
|
||||
label: "Dinner & Party",
|
||||
value: "Drinks and dancing",
|
||||
description: "The party continues indoors.",
|
||||
},
|
||||
],
|
||||
dressCode: {
|
||||
label: "Dress code",
|
||||
value: "Summer elegant",
|
||||
description: "Light fabrics, soft colors and a festive feel. No black, please.",
|
||||
},
|
||||
policies: [
|
||||
{ title: "Children", description: "Our celebration is adults only." },
|
||||
{ title: "Dogs", description: "Please leave your dogs at home." },
|
||||
{
|
||||
title: "Weather",
|
||||
description: "A beautiful indoor plan B is ready if it rains.",
|
||||
},
|
||||
{
|
||||
title: "Parking",
|
||||
description: "Parking is available on site; follow the signs.",
|
||||
},
|
||||
{
|
||||
title: "Accessibility",
|
||||
description: "The venue is mostly accessible. Contact us with questions.",
|
||||
},
|
||||
],
|
||||
},
|
||||
schedule: {
|
||||
eyebrow: "SCHEDULE",
|
||||
heading: "Here's how we celebrate.",
|
||||
introduction: "A day filled with love, laughter and unforgettable moments.",
|
||||
label: "The day, at a glance",
|
||||
timeline: [
|
||||
{
|
||||
time: "15:15",
|
||||
title: "Arrival",
|
||||
description: "Arrive, take your seat and get ready for the ceremony.",
|
||||
},
|
||||
{
|
||||
time: "16:00",
|
||||
title: "Ceremony",
|
||||
description: "We say I do in the garden.",
|
||||
},
|
||||
{
|
||||
time: "16:45",
|
||||
title: "Cocktails & Canapes",
|
||||
description: "Drinks, canapes and good company.",
|
||||
},
|
||||
{
|
||||
time: "18:30",
|
||||
title: "Dinner",
|
||||
description: "Time to eat, talk, laugh and celebrate.",
|
||||
},
|
||||
{
|
||||
time: "21:00",
|
||||
title: "Dessert",
|
||||
description: "Something sweet before things get louder.",
|
||||
},
|
||||
{
|
||||
time: "22:00-late",
|
||||
title: "Dancing",
|
||||
description: "Under the stars, until whenever.",
|
||||
},
|
||||
],
|
||||
quote: "Some moments turn into memories, and this one turns into forever.",
|
||||
},
|
||||
faq: {
|
||||
eyebrow: "FAQ",
|
||||
heading: "Questions? We've got you.",
|
||||
introduction: "Answers to the things you might be wondering about.",
|
||||
questions: [
|
||||
{
|
||||
question: "Can I bring a plus one?",
|
||||
answer: "Please bring only the guest named on your invitation.",
|
||||
},
|
||||
{
|
||||
question: "Are children invited?",
|
||||
answer: "Our celebration is adults only.",
|
||||
},
|
||||
{ question: "Can I bring my dog?", answer: "Please leave dogs at home." },
|
||||
{
|
||||
question: "Is there parking?",
|
||||
answer: "Yes, parking is available on site.",
|
||||
},
|
||||
{
|
||||
question: "Will there be transport?",
|
||||
answer: "Please plan your journey in advance.",
|
||||
},
|
||||
{
|
||||
question: "What happens if it rains?",
|
||||
answer: "We have a beautiful indoor plan B.",
|
||||
},
|
||||
{
|
||||
question: "What's the dress code?",
|
||||
answer: "Summer elegant. No black, please.",
|
||||
},
|
||||
{
|
||||
question: "Will the celebration go indoors?",
|
||||
answer: "Later, the party continues indoors.",
|
||||
},
|
||||
{
|
||||
question: "What food will be served?",
|
||||
answer: "A seasonal dinner; share dietary needs in advance.",
|
||||
},
|
||||
{
|
||||
question: "Can I help with a song or speech?",
|
||||
answer: "Coordinate surprises with our wedding party.",
|
||||
},
|
||||
],
|
||||
contact: {
|
||||
heading: "Still have a question?",
|
||||
description: "We're happy to help.",
|
||||
action: "Get in touch",
|
||||
},
|
||||
},
|
||||
rsvp: {
|
||||
eyebrow: "RSVP",
|
||||
heading: "We can't wait to celebrate with you.",
|
||||
paragraph: "Please let us know if you'll be joining us by",
|
||||
deadline: "May 15, 2027",
|
||||
helper: "We hope you can make it!",
|
||||
faqLink: "Questions? Visit our FAQ",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { InvitePageContent } from "./types";
|
||||
|
||||
const venue = "Luttelforster Muhle";
|
||||
const location = "Schwalmtal, Alemania";
|
||||
|
||||
export const es: InvitePageContent = {
|
||||
hero: {
|
||||
eyebrow: "10 DE JULIO DE 2027 · SCHWALMTAL",
|
||||
title: "Nicole y Raul",
|
||||
subtitle: "Nos casamos.",
|
||||
date: "10 de julio de 2027",
|
||||
location,
|
||||
},
|
||||
introduction: {
|
||||
heading: "Ven por el amor. Quedate por la fiesta.",
|
||||
paragraphs: [
|
||||
"Estamos deseando celebrar este dia especial contigo: una tarde en el jardin, una larga cena bajo las luces y una noche inolvidable.",
|
||||
"Gracias por formar parte de nuestra historia.",
|
||||
],
|
||||
facts: [
|
||||
{ label: "Fecha", value: "10 de julio de 2027", description: "Sabado" },
|
||||
{ label: "Lugar", value: venue, description: location },
|
||||
{ label: "Ceremonia", value: "16:00" },
|
||||
],
|
||||
},
|
||||
story: {
|
||||
eyebrow: "NUESTRA HISTORIA",
|
||||
heading: "El comienzo de nuestro para siempre.",
|
||||
paragraphs: [
|
||||
"Lugares distintos, caminos distintos, hasta que la vida nos unio. Lo que empezo con una chispa se hizo real.",
|
||||
"Viajamos, reimos, bailamos y sonamos. Ahora queremos celebrar este nuevo capitulo contigo.",
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
year: "2018",
|
||||
title: "Donde empezo",
|
||||
place: "Madrid",
|
||||
description: "Un encuentro casual durante un viaje cambio todo.",
|
||||
},
|
||||
{
|
||||
year: "2021",
|
||||
title: "Por el camino",
|
||||
description: "De viajes espontaneos a nuestros lugares favoritos. Alemania se convirtio en hogar.",
|
||||
},
|
||||
{
|
||||
year: "2027",
|
||||
title: "El siguiente capitulo",
|
||||
description: "Nos casamos y queremos celebrarlo contigo.",
|
||||
},
|
||||
],
|
||||
quote: "Lugares distintos, un viaje. Infinitos recuerdos. Siempre nosotros.",
|
||||
},
|
||||
details: {
|
||||
eyebrow: "DETALLES DE LA BODA",
|
||||
heading: "Todos los detalles importantes.",
|
||||
introduction: "Aqui encontraras todo lo necesario para nuestro gran dia.",
|
||||
facts: [
|
||||
{
|
||||
label: "Cuando",
|
||||
value: "10 de julio de 2027",
|
||||
description: "La ceremonia empieza a las 16:00",
|
||||
},
|
||||
{ label: "Donde", value: venue, description: location },
|
||||
{
|
||||
label: "Llegada",
|
||||
value: "Desde las 15:30",
|
||||
description: "Por favor, llega con tiempo.",
|
||||
},
|
||||
],
|
||||
expectations: [
|
||||
{
|
||||
label: "Ceremonia",
|
||||
value: "Al aire libre",
|
||||
description: "En el jardin, si el tiempo lo permite.",
|
||||
},
|
||||
{
|
||||
label: "Cena y fiesta",
|
||||
value: "Bebidas y baile",
|
||||
description: "La fiesta sigue en el interior.",
|
||||
},
|
||||
],
|
||||
dressCode: {
|
||||
label: "Codigo de vestimenta",
|
||||
value: "Elegante de verano",
|
||||
description: "Tejidos ligeros y colores suaves. Sin negro, por favor.",
|
||||
},
|
||||
policies: [
|
||||
{ title: "Ninos", description: "La celebracion es solo para adultos." },
|
||||
{ title: "Perros", description: "Por favor, dejalos en casa." },
|
||||
{ title: "Tiempo", description: "Hay un bonito plan B interior." },
|
||||
{ title: "Aparcamiento", description: "Disponible en el recinto." },
|
||||
{
|
||||
title: "Accesibilidad",
|
||||
description: "El lugar es mayormente accesible.",
|
||||
},
|
||||
],
|
||||
},
|
||||
schedule: {
|
||||
eyebrow: "PROGRAMA",
|
||||
heading: "Asi lo celebramos.",
|
||||
introduction: "Un dia lleno de amor, risas y momentos inolvidables.",
|
||||
label: "El dia de un vistazo",
|
||||
timeline: [
|
||||
{
|
||||
time: "15:15",
|
||||
title: "Llegada",
|
||||
description: "Llega, toma asiento y preparate.",
|
||||
},
|
||||
{
|
||||
time: "16:00",
|
||||
title: "Ceremonia",
|
||||
description: "Daremos el si quiero en el jardin.",
|
||||
},
|
||||
{
|
||||
time: "16:45",
|
||||
title: "Cocteles y canapes",
|
||||
description: "Brindis y buena compania.",
|
||||
},
|
||||
{
|
||||
time: "18:30",
|
||||
title: "Cena",
|
||||
description: "Comer, hablar, reir y celebrar.",
|
||||
},
|
||||
{
|
||||
time: "21:00",
|
||||
title: "Postre",
|
||||
description: "Algo dulce antes de subir el volumen.",
|
||||
},
|
||||
{
|
||||
time: "22:00-late",
|
||||
title: "Baile",
|
||||
description: "Bajo las estrellas, hasta cuando sea.",
|
||||
},
|
||||
],
|
||||
quote: "Algunos momentos se vuelven recuerdos, y este se vuelve para siempre.",
|
||||
},
|
||||
faq: {
|
||||
eyebrow: "PREGUNTAS FRECUENTES",
|
||||
heading: "Preguntas? Te ayudamos.",
|
||||
introduction: "Respuestas a lo que quizas te estes preguntando.",
|
||||
questions: [
|
||||
{
|
||||
question: "Puedo llevar acompanante?",
|
||||
answer: "Solo el invitado indicado en tu invitacion.",
|
||||
},
|
||||
{
|
||||
question: "Estan invitados los ninos?",
|
||||
answer: "La celebracion es solo para adultos.",
|
||||
},
|
||||
{
|
||||
question: "Puedo llevar a mi perro?",
|
||||
answer: "Por favor, deja a los perros en casa.",
|
||||
},
|
||||
{
|
||||
question: "Hay aparcamiento?",
|
||||
answer: "Si, disponible en el recinto.",
|
||||
},
|
||||
{
|
||||
question: "Habra transporte?",
|
||||
answer: "Organiza tu viaje con antelacion.",
|
||||
},
|
||||
{
|
||||
question: "Y si llueve?",
|
||||
answer: "Tenemos un bonito plan B interior.",
|
||||
},
|
||||
{
|
||||
question: "Cual es el codigo de vestimenta?",
|
||||
answer: "Elegante de verano; sin negro.",
|
||||
},
|
||||
{
|
||||
question: "La fiesta sigue dentro?",
|
||||
answer: "Mas tarde, la fiesta continua dentro.",
|
||||
},
|
||||
{
|
||||
question: "Que comida habra?",
|
||||
answer: "Cena de temporada; indica alergias antes.",
|
||||
},
|
||||
{
|
||||
question: "Puedo preparar una cancion o discurso?",
|
||||
answer: "Coordinalo con nuestro equipo de boda.",
|
||||
},
|
||||
],
|
||||
contact: {
|
||||
heading: "Tienes otra pregunta?",
|
||||
description: "Estaremos encantados de ayudar.",
|
||||
action: "Contactanos",
|
||||
},
|
||||
},
|
||||
rsvp: {
|
||||
eyebrow: "CONFIRMACION",
|
||||
heading: "Estamos deseando celebrar contigo.",
|
||||
paragraph: "Confirma tu asistencia antes del",
|
||||
deadline: "15 de mayo de 2027",
|
||||
helper: "Esperamos que puedas venir!",
|
||||
faqLink: "Preguntas? Visita las FAQ",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { InvitePageContent } from "./types";
|
||||
|
||||
const venue = "Luttelforster Muhle";
|
||||
const location = "Schwalmtal, Niemcy";
|
||||
|
||||
export const pl: InvitePageContent = {
|
||||
hero: {
|
||||
eyebrow: "10 LIPCA 2027 · SCHWALMTAL",
|
||||
title: "Nicole i Raul",
|
||||
subtitle: "Bierzemy slub.",
|
||||
date: "10 lipca 2027",
|
||||
location,
|
||||
},
|
||||
introduction: {
|
||||
heading: "Przyjdz dla milosci. Zostan dla zabawy.",
|
||||
paragraphs: [
|
||||
"Nie mozemy sie doczekac, by swietowac ten wyjatkowy dzien z Wami: popoludnie w ogrodzie, dluga kolacja pod swiatlami i niezapomniana noc.",
|
||||
"Dziekujemy, ze jestescie czescia naszej historii.",
|
||||
],
|
||||
facts: [
|
||||
{ label: "Data", value: "10 lipca 2027", description: "Sobota" },
|
||||
{ label: "Miejsce", value: venue, description: location },
|
||||
{ label: "Ceremonia", value: "16:00" },
|
||||
],
|
||||
},
|
||||
story: {
|
||||
eyebrow: "NASZA HISTORIA",
|
||||
heading: "Poczatek naszego na zawsze.",
|
||||
paragraphs: [
|
||||
"Rozne miejsca, rozne drogi, az zycie polaczylo nas razem. Iskra stala sie czyms prawdziwym.",
|
||||
"Podrozujemy, smiejemy sie, tanczymy i marzymy. Teraz chcemy swietowac ten nowy rozdzial z Wami.",
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
year: "2018",
|
||||
title: "Gdzie sie zaczelo",
|
||||
place: "Madryt",
|
||||
description: "Przypadkowe spotkanie podczas wyjazdu zmienilo wszystko.",
|
||||
},
|
||||
{
|
||||
year: "2021",
|
||||
title: "Gdzies po drodze",
|
||||
description: "Od spontanicznych podrozy do ulubionych miejsc. Niemcy staly sie domem.",
|
||||
},
|
||||
{
|
||||
year: "2027",
|
||||
title: "Kolejny rozdzial",
|
||||
description: "Bierzemy slub i chcemy swietowac z Wami.",
|
||||
},
|
||||
],
|
||||
quote: "Rozne miejsca, jedna podroz. Niezliczone wspomnienia. Na zawsze my.",
|
||||
},
|
||||
details: {
|
||||
eyebrow: "SZCZEGOLY WESELA",
|
||||
heading: "Wszystkie wazne szczegoly.",
|
||||
introduction: "Tutaj znajdziecie wszystko, co trzeba wiedziec o naszym dniu.",
|
||||
facts: [
|
||||
{
|
||||
label: "Kiedy",
|
||||
value: "10 lipca 2027",
|
||||
description: "Ceremonia o 16:00",
|
||||
},
|
||||
{ label: "Gdzie", value: venue, description: location },
|
||||
{
|
||||
label: "Przyjazd",
|
||||
value: "Od 15:30",
|
||||
description: "Prosimy o punktualne przybycie.",
|
||||
},
|
||||
],
|
||||
expectations: [
|
||||
{
|
||||
label: "Ceremonia",
|
||||
value: "W ogrodzie",
|
||||
description: "Jesli pogoda pozwoli.",
|
||||
},
|
||||
{
|
||||
label: "Kolacja i zabawa",
|
||||
value: "Drinki i tance",
|
||||
description: "Zabawa przeniesie sie do srodka.",
|
||||
},
|
||||
],
|
||||
dressCode: {
|
||||
label: "Stroj",
|
||||
value: "Letnia elegancja",
|
||||
description: "Lekkie tkaniny, delikatne kolory. Bez czerni, prosimy.",
|
||||
},
|
||||
policies: [
|
||||
{ title: "Dzieci", description: "Wesele jest tylko dla doroslych." },
|
||||
{ title: "Psy", description: "Prosmy zostawic psy w domu." },
|
||||
{ title: "Pogoda", description: "Mamy piekny plan B w srodku." },
|
||||
{ title: "Parking", description: "Parking jest na miejscu." },
|
||||
{
|
||||
title: "Dostepnosc",
|
||||
description: "Miejsce jest w wiekszosci dostepne.",
|
||||
},
|
||||
],
|
||||
},
|
||||
schedule: {
|
||||
eyebrow: "HARMONOGRAM",
|
||||
heading: "Tak swietujemy.",
|
||||
introduction: "Dzien pelen milosci, smiechu i niezapomnianych chwil.",
|
||||
label: "Dzien w skrocie",
|
||||
timeline: [
|
||||
{
|
||||
time: "15:15",
|
||||
title: "Przyjazd",
|
||||
description: "Przyjedz, zajmij miejsce i przygotuj sie.",
|
||||
},
|
||||
{
|
||||
time: "16:00",
|
||||
title: "Ceremonia",
|
||||
description: "Powiemy sobie tak w ogrodzie.",
|
||||
},
|
||||
{
|
||||
time: "16:45",
|
||||
title: "Koktajle i przekaski",
|
||||
description: "Toast i dobre towarzystwo.",
|
||||
},
|
||||
{
|
||||
time: "18:30",
|
||||
title: "Kolacja",
|
||||
description: "Jemy, rozmawiamy, smiejemy sie i swietujemy.",
|
||||
},
|
||||
{
|
||||
time: "21:00",
|
||||
title: "Deser",
|
||||
description: "Cos slodkiego przed glosniejsza zabawa.",
|
||||
},
|
||||
{
|
||||
time: "22:00-pozno",
|
||||
title: "Tance",
|
||||
description: "Pod gwiazdami, tak dlugo jak chcemy.",
|
||||
},
|
||||
],
|
||||
quote: "Niektore chwile staja sie wspomnieniami, a ta zostanie na zawsze.",
|
||||
},
|
||||
faq: {
|
||||
eyebrow: "FAQ",
|
||||
heading: "Pytania? Pomożemy.",
|
||||
introduction: "Odpowiedzi na pytania, ktore mozecie miec.",
|
||||
questions: [
|
||||
{
|
||||
question: "Czy moge przyprowadzic osobe towarzyszaca?",
|
||||
answer: "Prosimy przyprowadzic tylko osobe wskazana na zaproszeniu.",
|
||||
},
|
||||
{
|
||||
question: "Czy dzieci sa zaproszone?",
|
||||
answer: "Wesele jest tylko dla doroslych.",
|
||||
},
|
||||
{
|
||||
question: "Czy moge przyprowadzic psa?",
|
||||
answer: "Prosmy zostawic psy w domu.",
|
||||
},
|
||||
{
|
||||
question: "Czy jest parking?",
|
||||
answer: "Tak, parking jest na miejscu.",
|
||||
},
|
||||
{
|
||||
question: "Czy bedzie transport?",
|
||||
answer: "Zaplanujcie podroz z wyprzedzeniem.",
|
||||
},
|
||||
{
|
||||
question: "Co jesli bedzie padac?",
|
||||
answer: "Mamy piekny plan B w srodku.",
|
||||
},
|
||||
{ question: "Jaki jest stroj?", answer: "Letnia elegancja, bez czerni." },
|
||||
{
|
||||
question: "Czy zabawa bedzie w srodku?",
|
||||
answer: "Pozniej zabawa przeniesie sie do srodka.",
|
||||
},
|
||||
{
|
||||
question: "Jakie bedzie jedzenie?",
|
||||
answer: "Sezonowa kolacja; podajcie alergie wczesniej.",
|
||||
},
|
||||
{
|
||||
question: "Czy moge przygotowac piosenke lub przemowe?",
|
||||
answer: "Uzgodnijcie niespodzianki z nasza ekipa.",
|
||||
},
|
||||
],
|
||||
contact: {
|
||||
heading: "Masz jeszcze pytanie?",
|
||||
description: "Chetnie pomozemy.",
|
||||
action: "Skontaktuj sie",
|
||||
},
|
||||
},
|
||||
rsvp: {
|
||||
eyebrow: "RSVP",
|
||||
heading: "Nie mozemy sie doczekac, by swietowac z Wami.",
|
||||
paragraph: "Dajcie nam znac do",
|
||||
deadline: "15 maja 2027",
|
||||
helper: "Mamy nadzieje, ze bedziecie z nami!",
|
||||
faqLink: "Pytania? Zobacz FAQ",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
export type Fact = {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type TimelineItem = {
|
||||
time: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type StoryItem = {
|
||||
year: string;
|
||||
title: string;
|
||||
place?: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type Faq = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
export type Policy = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export interface InvitePageContent {
|
||||
hero: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
date: string;
|
||||
location: string;
|
||||
};
|
||||
introduction: {
|
||||
heading: string;
|
||||
paragraphs: readonly string[];
|
||||
facts: readonly Fact[];
|
||||
};
|
||||
story: {
|
||||
eyebrow: string;
|
||||
heading: string;
|
||||
paragraphs: readonly string[];
|
||||
timeline: readonly StoryItem[];
|
||||
quote: string;
|
||||
};
|
||||
details: {
|
||||
eyebrow: string;
|
||||
heading: string;
|
||||
introduction: string;
|
||||
facts: readonly Fact[];
|
||||
expectations: readonly Fact[];
|
||||
dressCode: Fact;
|
||||
policies: readonly Policy[];
|
||||
};
|
||||
schedule: {
|
||||
eyebrow: string;
|
||||
heading: string;
|
||||
introduction: string;
|
||||
label: string;
|
||||
timeline: readonly TimelineItem[];
|
||||
quote: string;
|
||||
};
|
||||
faq: {
|
||||
eyebrow: string;
|
||||
heading: string;
|
||||
introduction: string;
|
||||
questions: readonly Faq[];
|
||||
contact: {
|
||||
heading: string;
|
||||
description: string;
|
||||
action: string;
|
||||
};
|
||||
};
|
||||
rsvp: {
|
||||
eyebrow: string;
|
||||
heading: string;
|
||||
paragraph: string;
|
||||
deadline: string;
|
||||
helper: string;
|
||||
faqLink: string;
|
||||
};
|
||||
}
|
||||
|
After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 9.4 MiB |
|
After Width: | Height: | Size: 8.2 MiB |
|
After Width: | Height: | Size: 562 KiB |
|
After Width: | Height: | Size: 2.5 MiB |