initial commit
Build & Deployment / Build & Deploy (push) Failing after 5s

This commit is contained in:
Raul Lugo
2026-08-10 17:55:26 +02:00
parent 6429ed3fa3
commit 4ced446c9c
74 changed files with 9242 additions and 30 deletions
+350
View File
@@ -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>
)
}
+201
View File
@@ -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>
)
}
+139
View File
@@ -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>
)
}
+87
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}