365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import Link from "next/link"
|
|
import { notFound } from "next/navigation"
|
|
|
|
import { CopyInviteButton } from "@/components/admin/copy-invite-button"
|
|
import { DeleteInvitationDialog } from "@/components/admin/delete-invitation-dialog"
|
|
import { InvitationLanguageSelect } from "@/components/admin/invitation-language-select"
|
|
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 flex-wrap gap-2">
|
|
<Button asChild variant="outline">
|
|
<Link href="/admin/guests">Back to list</Link>
|
|
</Button>
|
|
<CopyInviteButton inviteUrl={inviteUrl} />
|
|
<DeleteInvitationDialog
|
|
invitationId={detail.invitation.id}
|
|
displayName={detail.invitation.display_name}
|
|
/>
|
|
</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="space-y-2">
|
|
<span className="text-muted-foreground">Language</span>
|
|
<InvitationLanguageSelect
|
|
invitationId={detail.invitation.id}
|
|
currentLanguage={detail.invitation.language}
|
|
displayName={detail.invitation.display_name}
|
|
/>
|
|
</div>
|
|
<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>
|
|
)
|
|
}
|