This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { CopyInviteButton } from "@/components/admin/copy-invite-button"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
getInvitationInteractions,
|
||||
updateToastRequestStatus,
|
||||
} from "@/lib/db/queries/interactions"
|
||||
import { getInvitationDetail } from "@/lib/db/queries/invitations"
|
||||
import { getPhotoUploadByInvitationId } from "@/lib/db/queries/uploads"
|
||||
import { getLanguageLabel } from "@/lib/i18n/config"
|
||||
import { createPresignedDownloadUrl } from "@/lib/storage/uploads"
|
||||
import { toastStatusSchema } from "@/lib/validation/interactions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function getInviteUrl(token: string) {
|
||||
return new URL(`/invite/${token}`, process.env.APP_URL ?? "http://localhost:3000").toString()
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null | undefined) {
|
||||
if (!date) return "—"
|
||||
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatBoolean(value: boolean | null) {
|
||||
if (value === true) return "Yes"
|
||||
if (value === false) return "No"
|
||||
return "Pending"
|
||||
}
|
||||
|
||||
function formatDateOnly(value: Date | string | null | undefined) {
|
||||
if (!value) return "—"
|
||||
|
||||
if (value instanceof Date) {
|
||||
return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format(value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
async function updateToastStatusAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
const toastId = formData.get("toastId")?.toString()
|
||||
const status = toastStatusSchema.safeParse(formData.get("status"))
|
||||
|
||||
if (!toastId || !status.success) return
|
||||
|
||||
await updateToastRequestStatus(toastId, status.data)
|
||||
}
|
||||
|
||||
export default async function GuestDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ created?: string }>
|
||||
}) {
|
||||
const [{ id }, { created }] = await Promise.all([params, searchParams])
|
||||
const [detail, interactions, photoUpload] = await Promise.all([
|
||||
getInvitationDetail(id),
|
||||
getInvitationInteractions(id),
|
||||
getPhotoUploadByInvitationId(id),
|
||||
])
|
||||
|
||||
if (!detail) notFound()
|
||||
|
||||
const inviteUrl = getInviteUrl(detail.invitation.token)
|
||||
const photoUrl = photoUpload ? await createPresignedDownloadUrl(photoUpload.object_key) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge>{detail.derivedStatus}</Badge>
|
||||
<Badge variant="secondary">{getLanguageLabel(detail.invitation.language)}</Badge>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{detail.invitation.display_name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Invitation details and member list.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/guests">Back to list</Link>
|
||||
</Button>
|
||||
<CopyInviteButton inviteUrl={inviteUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{created ? (
|
||||
<Alert>
|
||||
<AlertTitle>Invitation created</AlertTitle>
|
||||
<AlertDescription>
|
||||
Copy the private invite link below and share it only with this invitation group.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invite link</CardTitle>
|
||||
<CardDescription>Private URL for this invitation group.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{inviteUrl}
|
||||
</code>
|
||||
<CopyInviteButton inviteUrl={inviteUrl} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata</CardTitle>
|
||||
<CardDescription>Admin-managed invitation fields.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Contact email</span>
|
||||
<span className="text-right">{detail.invitation.contact_email ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Contact phone</span>
|
||||
<span className="text-right">{detail.invitation.contact_phone ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Max party size</span>
|
||||
<span>{detail.invitation.max_party_size}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Member edits</span>
|
||||
<span>{detail.invitation.allow_member_edits ? "Allowed" : "Disabled"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Updated</span>
|
||||
<span className="text-right">{formatDate(detail.invitation.updated_at)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Members</CardTitle>
|
||||
<CardDescription>
|
||||
Individual attendance and meal details from the guest RSVP.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Primary</TableHead>
|
||||
<TableHead>Attending</TableHead>
|
||||
<TableHead>Meal</TableHead>
|
||||
<TableHead>Dietary restrictions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="font-medium">{member.name}</TableCell>
|
||||
<TableCell>{member.is_primary_contact ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>{formatBoolean(member.attending)}</TableCell>
|
||||
<TableCell>{member.meal_preference ?? "—"}</TableCell>
|
||||
<TableCell>{member.dietary_restrictions ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Travel and RSVP details</CardTitle>
|
||||
<CardDescription>Group-level logistics submitted by the invitation group.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{detail.response ? (
|
||||
<div className="grid gap-4 text-sm md:grid-cols-2">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-muted-foreground">Submitted</p>
|
||||
<p className="mt-1 font-medium">{formatDate(detail.response.submitted_at)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-muted-foreground">Needs transport help</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{formatBoolean(detail.response.needs_transport_help)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-muted-foreground">Arrival date</p>
|
||||
<p className="mt-1 font-medium">{formatDateOnly(detail.response.arrival_date)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-muted-foreground">Departure date</p>
|
||||
<p className="mt-1 font-medium">{formatDateOnly(detail.response.departure_date)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-muted-foreground">Staying at</p>
|
||||
<p className="mt-1 font-medium">{detail.response.stay_location ?? "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 md:col-span-2">
|
||||
<p className="text-muted-foreground">Message to the couple</p>
|
||||
<p className="mt-1 whitespace-pre-wrap font-medium">
|
||||
{detail.response.message_to_couple ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No RSVP response submitted yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Admin notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{detail.invitation.notes ?? "No notes yet."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wedding surprise photo</CardTitle>
|
||||
<CardDescription>One image uploaded by this invitation group.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{photoUpload && photoUrl ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
|
||||
<div className="overflow-hidden rounded-lg border bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={photoUpload.original_filename ?? "Uploaded wedding activity photo"}
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-muted-foreground">Filename</p>
|
||||
<p className="mt-1 font-medium">{photoUpload.original_filename ?? "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-muted-foreground">Content type</p>
|
||||
<p className="mt-1 font-medium">{photoUpload.content_type ?? "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-muted-foreground">Size</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{photoUpload.size_bytes
|
||||
? `${(photoUpload.size_bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<a href={photoUrl} target="_blank" rel="noreferrer">
|
||||
Open signed preview
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No photo uploaded yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Toast requests</CardTitle>
|
||||
<CardDescription>Optional speech requests. Status is managed by admins.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{interactions.toastRequests.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{interactions.toastRequests.map((toast) => (
|
||||
<div className="rounded-lg border p-4" key={toast.id}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{toast.speaker_name}</p>
|
||||
<Badge variant="secondary">{toast.status}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Estimated time: {toast.estimated_minutes ?? "—"} minutes
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["pending", "accepted", "declined", "scheduled"] as const).map(
|
||||
(status) => (
|
||||
<form action={updateToastStatusAction} key={status}>
|
||||
<input type="hidden" name="toastId" value={toast.id} />
|
||||
<input type="hidden" name="status" value={status} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant={toast.status === status ? "default" : "outline"}
|
||||
>
|
||||
{status}
|
||||
</Button>
|
||||
</form>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No toast requests submitted yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { createInvitationWithMembers } from "@/lib/db/queries/invitations"
|
||||
import { supportedLanguages } from "@/lib/i18n/config"
|
||||
import { createInvitationSchema } from "@/lib/validation/invitation"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function getMembersFromFormData(formData: FormData) {
|
||||
return Array.from({ length: 8 }, (_, index) =>
|
||||
formData.get(`member-${index}`)?.toString().trim(),
|
||||
).filter((value): value is string => Boolean(value))
|
||||
}
|
||||
|
||||
async function createInvitationAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
const parsed = createInvitationSchema.safeParse({
|
||||
displayName: formData.get("displayName"),
|
||||
language: formData.get("language"),
|
||||
contactEmail: formData.get("contactEmail"),
|
||||
contactPhone: formData.get("contactPhone"),
|
||||
maxPartySize: formData.get("maxPartySize"),
|
||||
allowMemberEdits: formData.has("allowMemberEdits"),
|
||||
notes: formData.get("notes"),
|
||||
members: getMembersFromFormData(formData),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
const message = parsed.error.issues[0]?.message ?? "Invalid invitation data"
|
||||
redirect(`/admin/guests/new?error=${encodeURIComponent(message)}`)
|
||||
}
|
||||
|
||||
const invitation = await createInvitationWithMembers(parsed.data)
|
||||
redirect(`/admin/guests/${invitation.id}?created=1`)
|
||||
}
|
||||
|
||||
export default async function NewGuestPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>
|
||||
}) {
|
||||
const { error } = await searchParams
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">New invitation</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create a private invite link and define the people included in it.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/guests">Back to list</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not create invitation</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<form action={createInvitationAction}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invitation details</CardTitle>
|
||||
<CardDescription>
|
||||
The selected language will control the guest-facing invitation page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="displayName">Display name</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
placeholder="Ana & Luis or The Garcia Family"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">Language</Label>
|
||||
<Select name="language" required>
|
||||
<SelectTrigger id="language" className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedLanguages.map((language) => (
|
||||
<SelectItem key={language.code} value={language.code}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPartySize">Max party size</Label>
|
||||
<Input
|
||||
id="maxPartySize"
|
||||
name="maxPartySize"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactEmail">Contact email</Label>
|
||||
<Input id="contactEmail" name="contactEmail" type="email" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPhone">Contact phone</Label>
|
||||
<Input id="contactPhone" name="contactPhone" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="notes">Admin notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
placeholder="Anything only admins need to remember."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-md border p-4 sm:col-span-2">
|
||||
<Checkbox id="allowMemberEdits" name="allowMemberEdits" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="allowMemberEdits">Allow guest member edits</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Useful for flexible +1s. Guest editing is not implemented until the RSVP milestone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="font-medium">Initial members</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add at least one person. The first member is marked as the primary contact.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<div className="space-y-2" key={index}>
|
||||
<Label htmlFor={`member-${index}`}>
|
||||
Member {index + 1}
|
||||
{index === 0 ? " (primary)" : ""}
|
||||
</Label>
|
||||
<Input
|
||||
id={`member-${index}`}
|
||||
name={`member-${index}`}
|
||||
placeholder={index === 0 ? "Ana Garcia" : "Optional"}
|
||||
required={index === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/guests">Cancel</Link>
|
||||
</Button>
|
||||
<Button type="submit">Create invitation</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { CopyInviteButton } from "@/components/admin/copy-invite-button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { listInvitations } from "@/lib/db/queries/invitations"
|
||||
import { getLanguageLabel } from "@/lib/i18n/config"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function getInviteUrl(token: string) {
|
||||
return new URL(`/invite/${token}`, process.env.APP_URL ?? "http://localhost:3000").toString()
|
||||
}
|
||||
|
||||
function getStatusVariant(status: string): "default" | "secondary" | "outline" {
|
||||
if (status === "Attending") return "default"
|
||||
if (status === "Pending") return "secondary"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
export default async function GuestsPage() {
|
||||
const invitations = await listInvitations()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Guests</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage invitation groups and private invite links.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/guests/new">Create invitation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invitation list</CardTitle>
|
||||
<CardDescription>
|
||||
One row per invitation group, household, couple, family, or individual guest.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{invitations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center">
|
||||
<p className="font-medium">No invitations yet</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Create the first invitation to generate a private guest link.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/admin/guests/new">Create invitation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invitation</TableHead>
|
||||
<TableHead>Language</TableHead>
|
||||
<TableHead>Members</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invitations.map((invitation) => {
|
||||
const inviteUrl = getInviteUrl(invitation.token)
|
||||
|
||||
return (
|
||||
<TableRow key={invitation.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Button asChild variant="link" className="h-auto p-0 font-medium">
|
||||
<Link href={`/admin/guests/${invitation.id}`}>
|
||||
{invitation.displayName}
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Max party size: {invitation.maxPartySize}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getLanguageLabel(invitation.language)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p>{invitation.membersCount}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{invitation.attendingCount} attending · {invitation.pendingCount} pending
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(invitation.derivedStatus)}>
|
||||
{invitation.derivedStatus}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>{invitation.contactEmail ?? "—"}</p>
|
||||
<p className="text-muted-foreground">{invitation.contactPhone ?? "—"}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
<CopyInviteButton inviteUrl={inviteUrl} />
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/admin/guests/${invitation.id}`}>View</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth, signOut } from "@/auth"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { isAdminEmail } from "@/lib/admin-auth"
|
||||
|
||||
function getInitials(name: string | null | undefined, email: string | null | undefined) {
|
||||
const source = name || email || "Admin"
|
||||
|
||||
return source
|
||||
.split(/[\s@.]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("")
|
||||
}
|
||||
|
||||
async function signOutAction() {
|
||||
"use server"
|
||||
|
||||
await signOut({ redirectTo: "/" })
|
||||
}
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.email) {
|
||||
redirect("/sign-in")
|
||||
}
|
||||
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
redirect("/auth-error")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40">
|
||||
<header className="border-b bg-background">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
<Button asChild variant="link" className="h-auto p-0 text-lg font-semibold">
|
||||
<Link href="/admin">Wedding admin</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage guest invitations, responses, and wedding details.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden text-right sm:block">
|
||||
<p className="text-sm font-medium">{session.user.name ?? "Admin"}</p>
|
||||
<p className="text-xs text-muted-foreground">{session.user.email}</p>
|
||||
</div>
|
||||
<Avatar>
|
||||
<AvatarImage src={session.user.image ?? undefined} alt="Admin avatar" />
|
||||
<AvatarFallback>
|
||||
{getInitials(session.user.name, session.user.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<form action={signOutAction}>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</header>
|
||||
<nav className="border-b bg-background">
|
||||
<div className="mx-auto flex max-w-6xl gap-2 px-6 py-2">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin">Dashboard</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin/guests">Guests</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { CalendarHeart, ImageIcon, MailCheck, UsersRound } from "lucide-react"
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
const dashboardCards = [
|
||||
{
|
||||
title: "Invitations",
|
||||
value: "—",
|
||||
description: "Guest group management starts in the next milestone.",
|
||||
icon: UsersRound,
|
||||
},
|
||||
{
|
||||
title: "RSVPs",
|
||||
value: "—",
|
||||
description: "Per-member responses will appear here later.",
|
||||
icon: MailCheck,
|
||||
},
|
||||
{
|
||||
title: "Photo uploads",
|
||||
value: "—",
|
||||
description: "MinIO uploads are planned after RSVP interactions.",
|
||||
icon: ImageIcon,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Badge variant="secondary" className="w-fit">
|
||||
Milestone 02
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Admin dashboard</h1>
|
||||
<p className="max-w-2xl text-muted-foreground">
|
||||
The protected admin shell is ready. Invitation management will be added
|
||||
in the next milestone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<CalendarHeart />
|
||||
<AlertTitle>Admin access is protected</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only Google accounts listed in <code>ADMIN_EMAILS</code> can access this area.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{dashboardCards.map((card) => {
|
||||
const Icon = card.icon
|
||||
|
||||
return (
|
||||
<Card key={card.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||
<CardDescription>{card.description}</CardDescription>
|
||||
</div>
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-semibold">{card.value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { getInvitationByToken } from "@/lib/db/queries/invitations"
|
||||
import { getPhotoUploadByInvitationId, upsertPhotoUpload } from "@/lib/db/queries/uploads"
|
||||
import { isRsvpClosed } from "@/lib/rsvp-deadline"
|
||||
import { getUploadBucket } from "@/lib/storage/s3"
|
||||
import { deleteUploadObject, isObjectKeyForInvitation } from "@/lib/storage/uploads"
|
||||
import { completeUploadSchema } from "@/lib/validation/upload"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isRsvpClosed()) {
|
||||
return NextResponse.json({ message: "Upload deadline has passed" }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = completeUploadSchema.safeParse(await request.json())
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ message: "Invalid upload completion" }, { status: 400 })
|
||||
}
|
||||
|
||||
const invitation = await getInvitationByToken(parsed.data.token)
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ message: "Invitation not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!isObjectKeyForInvitation(parsed.data.objectKey, invitation.invitation.id)) {
|
||||
return NextResponse.json({ message: "Invalid object key" }, { status: 400 })
|
||||
}
|
||||
|
||||
const previousUpload = await getPhotoUploadByInvitationId(invitation.invitation.id)
|
||||
const upload = await upsertPhotoUpload({
|
||||
invitationId: invitation.invitation.id,
|
||||
bucket: getUploadBucket(),
|
||||
objectKey: parsed.data.objectKey,
|
||||
originalFilename: parsed.data.filename,
|
||||
contentType: parsed.data.contentType,
|
||||
sizeBytes: parsed.data.sizeBytes,
|
||||
})
|
||||
|
||||
if (previousUpload && previousUpload.object_key !== parsed.data.objectKey) {
|
||||
await deleteUploadObject(previousUpload.object_key).catch(() => undefined)
|
||||
}
|
||||
|
||||
return NextResponse.json({ upload })
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { getInvitationByToken } from "@/lib/db/queries/invitations"
|
||||
import { isRsvpClosed } from "@/lib/rsvp-deadline"
|
||||
import {
|
||||
createInvitationUploadKey,
|
||||
createPresignedUploadUrl,
|
||||
} from "@/lib/storage/uploads"
|
||||
import { presignUploadSchema } from "@/lib/validation/upload"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isRsvpClosed()) {
|
||||
return NextResponse.json({ message: "Upload deadline has passed" }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = presignUploadSchema.safeParse(await request.json())
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ message: "Invalid upload request" }, { status: 400 })
|
||||
}
|
||||
|
||||
const invitation = await getInvitationByToken(parsed.data.token)
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ message: "Invitation not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const objectKey = createInvitationUploadKey(
|
||||
invitation.invitation.id,
|
||||
parsed.data.filename,
|
||||
)
|
||||
const uploadUrl = await createPresignedUploadUrl({
|
||||
objectKey,
|
||||
contentType: parsed.data.contentType,
|
||||
sizeBytes: parsed.data.sizeBytes,
|
||||
})
|
||||
|
||||
return NextResponse.json({ objectKey, uploadUrl })
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted px-6 py-12">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Access denied</CardTitle>
|
||||
<CardDescription>
|
||||
This Google account is not allowed to access the wedding admin portal.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Unauthorized account</AlertTitle>
|
||||
<AlertDescription>
|
||||
Ask the site owners to add your email to the admin allowlist if this
|
||||
is unexpected.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/sign-in">Try another Google account</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+69
-11
@@ -1,26 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import {
|
||||
getInvitationInteractions,
|
||||
replaceInvitationInteractions,
|
||||
} from "@/lib/db/queries/interactions"
|
||||
import { getInvitationByToken, submitInvitationResponse } from "@/lib/db/queries/invitations"
|
||||
import { getPhotoUploadByInvitationId } from "@/lib/db/queries/uploads"
|
||||
import { getGuestDictionary } from "@/lib/i18n/get-dictionary"
|
||||
import { getRsvpDeadline, isRsvpClosed } from "@/lib/rsvp-deadline"
|
||||
import { PhotoUpload } from "@/components/invite/photo-upload"
|
||||
import {
|
||||
interactionSubmissionSchema,
|
||||
} from "@/lib/validation/interactions"
|
||||
import {
|
||||
mealPreferenceSchema,
|
||||
submitInvitationResponseSchema,
|
||||
type MealPreference,
|
||||
} from "@/lib/validation/response"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const mealOptions: MealPreference[] = ["meat", "fish", "vegetarian", "vegan", "kids", "other"]
|
||||
|
||||
function formatDateForInput(value: Date | string | null | undefined) {
|
||||
if (!value) return ""
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function getOptionalString(formData: FormData, key: string) {
|
||||
return formData.get(key)?.toString() ?? ""
|
||||
}
|
||||
|
||||
function getTransportHelp(value: FormDataEntryValue | null) {
|
||||
if (value === "yes") return true
|
||||
if (value === "no") return false
|
||||
return null
|
||||
}
|
||||
|
||||
function getToastRequestFromFormData(formData: FormData) {
|
||||
if (!formData.has("wantsToast")) return null
|
||||
|
||||
const speakerName = getOptionalString(formData, "toastSpeakerName")
|
||||
if (!speakerName) return null
|
||||
|
||||
const memberId = getOptionalString(formData, "toastMemberId")
|
||||
|
||||
return {
|
||||
memberId: memberId || null,
|
||||
speakerName,
|
||||
estimatedMinutes: getOptionalString(formData, "toastEstimatedMinutes"),
|
||||
}
|
||||
}
|
||||
|
||||
function deadlineMessage(template: string) {
|
||||
const deadline = getRsvpDeadline()
|
||||
return template.replace("{date}", deadline ?? "the deadline")
|
||||
}
|
||||
|
||||
async function submitRsvpAction(token: string, formData: FormData) {
|
||||
"use server"
|
||||
|
||||
if (isRsvpClosed()) {
|
||||
redirect(`/invite/${token}?error=closed`)
|
||||
}
|
||||
|
||||
const invitation = await getInvitationByToken(token)
|
||||
|
||||
if (!invitation) {
|
||||
redirect(`/invite/${token}?error=invalid`)
|
||||
}
|
||||
|
||||
const members = invitation.members.map((member) => {
|
||||
const attendingValue = formData.get(`attending-${member.id}`)
|
||||
|
||||
if (attendingValue !== "yes" && attendingValue !== "no") {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
const attending = attendingValue === "yes"
|
||||
const rawMealPreference = formData.get(`meal-${member.id}`)?.toString() ?? ""
|
||||
const mealPreference = mealPreferenceSchema.safeParse(rawMealPreference)
|
||||
|
||||
return {
|
||||
memberId: member.id,
|
||||
attending,
|
||||
mealPreference: attending && mealPreference.success ? mealPreference.data : null,
|
||||
dietaryRestrictions: getOptionalString(formData, `dietary-${member.id}`),
|
||||
}
|
||||
})
|
||||
|
||||
const parsed = submitInvitationResponseSchema.safeParse({
|
||||
token,
|
||||
members,
|
||||
arrivalDate: formData.get("arrivalDate"),
|
||||
departureDate: formData.get("departureDate"),
|
||||
stayLocation: formData.get("stayLocation"),
|
||||
needsTransportHelp: getTransportHelp(formData.get("needsTransportHelp")),
|
||||
messageToCouple: formData.get("messageToCouple"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
const interactions = interactionSubmissionSchema.safeParse({
|
||||
invitationId: invitation.invitation.id,
|
||||
toastRequest: getToastRequestFromFormData(formData),
|
||||
})
|
||||
|
||||
if (!interactions.success) {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
await submitInvitationResponse(parsed.data)
|
||||
await replaceInvitationInteractions(interactions.data)
|
||||
redirect(`/invite/${token}?submitted=1`)
|
||||
}
|
||||
|
||||
export default async function InvitePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ token: string }>
|
||||
searchParams: Promise<{ submitted?: string; error?: string }>
|
||||
}) {
|
||||
const [{ token }, { submitted, error }] = await Promise.all([params, searchParams])
|
||||
const detail = await getInvitationByToken(token)
|
||||
|
||||
if (!detail) {
|
||||
const dictionary = getGuestDictionary("en")
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-stone-50 px-6 py-12">
|
||||
<section className="max-w-lg rounded-3xl border bg-white p-8 text-center shadow-sm">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-stone-500">
|
||||
{dictionary.hero.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold text-stone-950">
|
||||
{dictionary.invalid.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-stone-600">{dictionary.invalid.description}</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const dictionary = getGuestDictionary(detail.invitation.language)
|
||||
const [interactions, photoUpload] = await Promise.all([
|
||||
getInvitationInteractions(detail.invitation.id),
|
||||
getPhotoUploadByInvitationId(detail.invitation.id),
|
||||
])
|
||||
const closed = isRsvpClosed()
|
||||
const action = submitRsvpAction.bind(null, token)
|
||||
const greeting = dictionary.hero.greeting.replace("{name}", detail.invitation.display_name)
|
||||
const toastRequest = interactions.toastRequests[0]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 text-stone-950">
|
||||
<section className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-12">
|
||||
<div className="rounded-[2rem] bg-white p-8 shadow-sm ring-1 ring-stone-200 md:p-12">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.3em] text-stone-500">
|
||||
{dictionary.hero.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-5 max-w-3xl text-4xl font-semibold tracking-tight md:text-6xl">
|
||||
{dictionary.hero.title}
|
||||
</h1>
|
||||
<p className="mt-6 text-xl font-medium text-stone-800">{greeting}</p>
|
||||
<p className="mt-3 max-w-2xl text-stone-600">{dictionary.hero.description}</p>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-emerald-950">
|
||||
{dictionary.form.submitted}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-red-950">
|
||||
{error === "closed" ? dictionary.errors.closed : dictionary.errors.generic}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.dateLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.dateValue}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.locationLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.locationValue}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.accommodationLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.accommodationValue}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form action={action} className="space-y-8">
|
||||
<section className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="flex flex-col gap-2 border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.rsvp}</h2>
|
||||
<p className="text-stone-600">
|
||||
{closed
|
||||
? dictionary.deadline.closed
|
||||
: deadlineMessage(dictionary.deadline.open)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{detail.members.map((member) => (
|
||||
<fieldset className="space-y-5 py-6" disabled={closed} key={member.id}>
|
||||
<legend className="text-lg font-semibold">{member.name}</legend>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{dictionary.form.attending}</p>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={`attending-${member.id}`}
|
||||
value="yes"
|
||||
defaultChecked={member.attending === true}
|
||||
required
|
||||
/>
|
||||
{dictionary.form.yes}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={`attending-${member.id}`}
|
||||
value="no"
|
||||
defaultChecked={member.attending === false}
|
||||
required
|
||||
/>
|
||||
{dictionary.form.no}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.mealPreference}</span>
|
||||
<select
|
||||
name={`meal-${member.id}`}
|
||||
defaultValue={member.meal_preference ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
>
|
||||
<option value="">{dictionary.form.mealPlaceholder}</option>
|
||||
{mealOptions.map((meal) => (
|
||||
<option key={meal} value={meal}>
|
||||
{dictionary.meals[meal]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.dietaryRestrictions}</span>
|
||||
<input
|
||||
name={`dietary-${member.id}`}
|
||||
defaultValue={member.dietary_restrictions ?? ""}
|
||||
placeholder={dictionary.form.noRestrictions}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<fieldset disabled={closed} className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.travel}</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.arrivalDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
name="arrivalDate"
|
||||
defaultValue={formatDateForInput(detail.response?.arrival_date)}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.departureDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
name="departureDate"
|
||||
defaultValue={formatDateForInput(detail.response?.departure_date)}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.stayLocation}</span>
|
||||
<input
|
||||
name="stayLocation"
|
||||
defaultValue={detail.response?.stay_location ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{dictionary.form.needsTransportHelp}</p>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="needsTransportHelp"
|
||||
value="yes"
|
||||
defaultChecked={detail.response?.needs_transport_help === true}
|
||||
/>
|
||||
{dictionary.form.yes}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="needsTransportHelp"
|
||||
value="no"
|
||||
defaultChecked={detail.response?.needs_transport_help === false}
|
||||
/>
|
||||
{dictionary.form.no}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.messageToCouple}</span>
|
||||
<textarea
|
||||
name="messageToCouple"
|
||||
defaultValue={detail.response?.message_to_couple ?? ""}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset disabled={closed} className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.interactions}</h2>
|
||||
<p className="mt-2 text-stone-600">{dictionary.interactions.intro}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-10">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{dictionary.interactions.toastTitle}</h3>
|
||||
<p className="text-sm text-stone-600">
|
||||
{dictionary.interactions.toastDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="wantsToast" defaultChecked={Boolean(toastRequest)} />
|
||||
{dictionary.interactions.wantsToast}
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.interactions.speakerName}</span>
|
||||
<input
|
||||
name="toastSpeakerName"
|
||||
defaultValue={toastRequest?.speaker_name ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.interactions.estimatedMinutes}</span>
|
||||
<input
|
||||
name="toastEstimatedMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={toastRequest?.estimated_minutes ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<select name="toastMemberId" defaultValue={toastRequest?.member_id ?? ""} className="hidden">
|
||||
<option value="" />
|
||||
{detail.members.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<PhotoUpload
|
||||
token={token}
|
||||
labels={dictionary.upload}
|
||||
disabled={closed}
|
||||
existingFilename={photoUpload?.original_filename ?? null}
|
||||
/>
|
||||
|
||||
{!closed ? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-stone-950 px-6 py-3 font-medium text-white transition hover:bg-stone-800"
|
||||
>
|
||||
{dictionary.form.submit}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth, signIn } from "@/auth"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { isAdminEmail } from "@/lib/admin-auth"
|
||||
|
||||
async function signInWithGoogle() {
|
||||
"use server"
|
||||
|
||||
await signIn("google", { redirectTo: "/admin" })
|
||||
}
|
||||
|
||||
export default async function SignInPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (isAdminEmail(session?.user?.email)) {
|
||||
redirect("/admin")
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted px-6 py-12">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Wedding admin</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in with an authorized Google account to manage invitations.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert>
|
||||
<AlertTitle>Private area</AlertTitle>
|
||||
<AlertDescription>
|
||||
Access is restricted to the admin emails configured for this wedding.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<form action={signInWithGoogle}>
|
||||
<Button className="w-full" type="submit">
|
||||
Continue with Google
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user