This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# Dependencies and generated output
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Version control and deployment metadata
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.vercel/
|
||||||
|
|
||||||
|
# Local environment files and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Logs, caches, and local tooling
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.cache/
|
||||||
|
.turbo/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Files not required by the image build
|
||||||
|
Dockerfile*
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
opencode.json
|
||||||
|
plans/
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
POSTGRES_DB=wedding
|
||||||
|
POSTGRES_USER=wedding
|
||||||
|
POSTGRES_PASSWORD=wedding
|
||||||
|
DATABASE_URL=postgres://wedding:wedding@wedding-db:5432/wedding
|
||||||
|
|
||||||
|
AUTH_SECRET=change-me
|
||||||
|
AUTH_GOOGLE_ID=change-me
|
||||||
|
AUTH_GOOGLE_SECRET=change-me
|
||||||
|
ADMIN_EMAILS=person-one@example.com,person-two@example.com
|
||||||
|
|
||||||
|
APP_URL=https://wedding.example.com
|
||||||
|
AUTH_URL=https://wedding.example.com
|
||||||
|
RSVP_DEADLINE=2026-08-01
|
||||||
|
|
||||||
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD=minioadmin
|
||||||
|
S3_ENDPOINT=https://wedding-minio.example.com
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=wedding-uploads
|
||||||
|
S3_ACCESS_KEY_ID=minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
TRAEFIK_NETWORK=traefik
|
||||||
|
TRAEFIK_ENTRYPOINT=websecure
|
||||||
|
TRAEFIK_CERT_RESOLVER=letsencrypt
|
||||||
|
WEDDING_APP_HOST=wedding.example.com
|
||||||
|
WEDDING_MINIO_API_HOST=wedding-minio.example.com
|
||||||
|
WEDDING_MINIO_CONSOLE_HOST=wedding-minio-console.example.com
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Build & Deployment
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REMOTE_WORKING_DIRECTORY: compose/wedding-website
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: repository
|
||||||
|
|
||||||
|
- name: Copy Repository over SSH
|
||||||
|
uses: garygrossgarten/github-action-scp@release
|
||||||
|
with:
|
||||||
|
local: repository
|
||||||
|
remote: ${{ env.REMOTE_WORKING_DIRECTORY }}
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
|
privateKey: ${{ secrets.SSH_KEY }}
|
||||||
|
rmRemote: true
|
||||||
|
|
||||||
|
- name: Build ENV
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.WEDDING_WEBSITE_ENV }}" | base64 -d > .env
|
||||||
|
|
||||||
|
- name: Copy ENV over SSH
|
||||||
|
uses: garygrossgarten/github-action-scp@release
|
||||||
|
with:
|
||||||
|
local: .env
|
||||||
|
remote: ${{ env.REMOTE_WORKING_DIRECTORY }}/.env
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
|
privateKey: ${{ secrets.SSH_KEY }}
|
||||||
|
|
||||||
|
- name: Docker Compose Commands
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
script_stop: true
|
||||||
|
script: |
|
||||||
|
cd ${{ env.REMOTE_WORKING_DIRECTORY }}
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d wedding-db wedding-minio wedding-minio-init
|
||||||
|
docker compose up --force-recreate --no-deps --abort-on-container-exit --exit-code-from wedding-migrate wedding-migrate
|
||||||
|
docker compose up -d wedding-app
|
||||||
|
docker compose images
|
||||||
|
docker image prune --all --force
|
||||||
|
|
||||||
|
- name: Clean .env
|
||||||
|
if: always()
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd ${{ env.REMOTE_WORKING_DIRECTORY }}
|
||||||
|
rm -f .env
|
||||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
ARG NODE_VERSION=24.13.0-alpine3.23
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION} AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION} AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
SKIP_ENV_VALIDATION=1
|
||||||
|
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION} AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
HOSTNAME=0.0.0.0 \
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
RUN mkdir .next && chown node:node .next
|
||||||
|
|
||||||
|
COPY --from=builder --chown=node:node /app/public ./public
|
||||||
|
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=node:node /app/scripts ./scripts
|
||||||
|
COPY --from=builder --chown=node:node /app/migrations ./migrations
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+66
-8
@@ -1,26 +1,84 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--radius: 0.625rem;
|
||||||
--foreground: #171717;
|
--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 {
|
@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-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--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-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark {
|
||||||
:root {
|
--background: oklch(0.129 0.042 264.695);
|
||||||
--background: #0a0a0a;
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
--foreground: #ededed;
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
@apply bg-background text-foreground;
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import NextAuth from "next-auth"
|
||||||
|
import Google from "next-auth/providers/google"
|
||||||
|
|
||||||
|
import { isAdminEmail } from "@/lib/admin-auth"
|
||||||
|
|
||||||
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
|
providers: [Google],
|
||||||
|
pages: {
|
||||||
|
signIn: "/sign-in",
|
||||||
|
error: "/auth-error",
|
||||||
|
},
|
||||||
|
trustHost: true,
|
||||||
|
callbacks: {
|
||||||
|
signIn({ profile, user }) {
|
||||||
|
const email = user.email ?? (typeof profile?.email === "string" ? profile.email : null)
|
||||||
|
|
||||||
|
return isAdminEmail(email)
|
||||||
|
},
|
||||||
|
authorized({ auth }) {
|
||||||
|
return isAdminEmail(auth?.user?.email)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export function CopyInviteButton({ inviteUrl }: { inviteUrl: string }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
async function copyInviteUrl() {
|
||||||
|
await navigator.clipboard.writeText(inviteUrl)
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={copyInviteUrl}>
|
||||||
|
{copied ? "Copied" : "Copy link"}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef, useState } from "react"
|
||||||
|
|
||||||
|
import { allowedImageContentTypes, maxUploadSizeBytes } from "@/lib/validation/upload"
|
||||||
|
|
||||||
|
interface PhotoUploadLabels {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
current: string
|
||||||
|
choose: string
|
||||||
|
upload: string
|
||||||
|
replace: string
|
||||||
|
uploading: string
|
||||||
|
success: string
|
||||||
|
error: string
|
||||||
|
disabled: string
|
||||||
|
requirements: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoUpload({
|
||||||
|
token,
|
||||||
|
labels,
|
||||||
|
disabled,
|
||||||
|
existingFilename,
|
||||||
|
}: {
|
||||||
|
token: string
|
||||||
|
labels: PhotoUploadLabels
|
||||||
|
disabled: boolean
|
||||||
|
existingFilename: string | null
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [filename, setFilename] = useState(existingFilename)
|
||||||
|
const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
||||||
|
|
||||||
|
async function uploadSelectedFile() {
|
||||||
|
const file = inputRef.current?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (
|
||||||
|
!allowedImageContentTypes.includes(
|
||||||
|
file.type as (typeof allowedImageContentTypes)[number],
|
||||||
|
) ||
|
||||||
|
file.size > maxUploadSizeBytes
|
||||||
|
) {
|
||||||
|
setStatus("error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("uploading")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const presignResponse = await fetch("/api/uploads/presign", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!presignResponse.ok) throw new Error("Could not presign upload")
|
||||||
|
|
||||||
|
const { uploadUrl, objectKey } = (await presignResponse.json()) as {
|
||||||
|
uploadUrl: string
|
||||||
|
objectKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": file.type },
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) throw new Error("Could not upload file")
|
||||||
|
|
||||||
|
const completeResponse = await fetch("/api/uploads/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
objectKey,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!completeResponse.ok) throw new Error("Could not complete upload")
|
||||||
|
|
||||||
|
setFilename(file.name)
|
||||||
|
setStatus("success")
|
||||||
|
if (inputRef.current) inputRef.current.value = ""
|
||||||
|
} catch {
|
||||||
|
setStatus("error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{filename ? (
|
||||||
|
<p className="rounded-2xl bg-stone-100 px-4 py-3 text-sm text-stone-700">
|
||||||
|
{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">
|
||||||
|
{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>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={allowedImageContentTypes.join(",")}
|
||||||
|
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{status === "uploading"
|
||||||
|
? labels.uploading
|
||||||
|
: filename
|
||||||
|
? labels.replace
|
||||||
|
: labels.upload}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "success" ? (
|
||||||
|
<p className="text-sm text-emerald-700">{labels.success}</p>
|
||||||
|
) : null}
|
||||||
|
{status === "error" ? <p className="text-sm text-red-700">{labels.error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedOrigins": ["*"],
|
||||||
|
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"ExposeHeaders": ["ETag"],
|
||||||
|
"MaxAgeSeconds": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
services:
|
||||||
|
wedding-db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: wedding-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-wedding}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-wedding}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wedding}
|
||||||
|
volumes:
|
||||||
|
- wedding-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wedding} -d ${POSTGRES_DB:-wedding}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- wedding-private
|
||||||
|
|
||||||
|
wedding-minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: wedding-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
volumes:
|
||||||
|
- wedding-minio-data:/data
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik}
|
||||||
|
- traefik.http.routers.wedding-minio-api.rule=Host(`${WEDDING_MINIO_API_HOST:-wedding-minio.localhost}`)
|
||||||
|
- traefik.http.routers.wedding-minio-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||||
|
- traefik.http.routers.wedding-minio-api.tls=true
|
||||||
|
- traefik.http.routers.wedding-minio-api.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}
|
||||||
|
- traefik.http.routers.wedding-minio-api.service=wedding-minio-api
|
||||||
|
- traefik.http.services.wedding-minio-api.loadbalancer.server.port=9000
|
||||||
|
- traefik.http.routers.wedding-minio-console.rule=Host(`${WEDDING_MINIO_CONSOLE_HOST:-wedding-minio-console.localhost}`)
|
||||||
|
- traefik.http.routers.wedding-minio-console.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||||
|
- traefik.http.routers.wedding-minio-console.tls=true
|
||||||
|
- traefik.http.routers.wedding-minio-console.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}
|
||||||
|
- traefik.http.routers.wedding-minio-console.service=wedding-minio-console
|
||||||
|
- traefik.http.services.wedding-minio-console.loadbalancer.server.port=9001
|
||||||
|
networks:
|
||||||
|
- wedding-private
|
||||||
|
- traefik-public
|
||||||
|
|
||||||
|
wedding-minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
container_name: wedding-minio-init
|
||||||
|
depends_on:
|
||||||
|
- wedding-minio
|
||||||
|
environment:
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-wedding-uploads}
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
volumes:
|
||||||
|
- ./deployment/minio-cors.json:/etc/minio/cors.json:ro
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
until mc alias set local http://wedding-minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD; do
|
||||||
|
echo 'Waiting for MinIO...';
|
||||||
|
sleep 2;
|
||||||
|
done;
|
||||||
|
mc mb --ignore-existing local/$$S3_BUCKET &&
|
||||||
|
mc anonymous set none local/$$S3_BUCKET &&
|
||||||
|
mc cors set local/$$S3_BUCKET /etc/minio/cors.json
|
||||||
|
"
|
||||||
|
networks:
|
||||||
|
- wedding-private
|
||||||
|
|
||||||
|
wedding-migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: wedding-website:latest
|
||||||
|
container_name: wedding-migrate
|
||||||
|
restart: "no"
|
||||||
|
command: ["node", "scripts/migrate-sql.mjs"]
|
||||||
|
depends_on:
|
||||||
|
wedding-db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgres://wedding:wedding@wedding-db:5432/wedding}
|
||||||
|
networks:
|
||||||
|
- wedding-private
|
||||||
|
|
||||||
|
wedding-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: wedding-website:latest
|
||||||
|
container_name: wedding-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
wedding-db:
|
||||||
|
condition: service_healthy
|
||||||
|
wedding-minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
wedding-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgres://wedding:wedding@wedding-db:5432/wedding}
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID}
|
||||||
|
AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET}
|
||||||
|
ADMIN_EMAILS: ${ADMIN_EMAILS}
|
||||||
|
APP_URL: ${APP_URL:-https://wedding.localhost}
|
||||||
|
AUTH_URL: ${AUTH_URL:-https://wedding.localhost}
|
||||||
|
RSVP_DEADLINE: ${RSVP_DEADLINE}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-http://wedding-minio:9000}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-wedding-uploads}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||||
|
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik}
|
||||||
|
- traefik.http.routers.wedding-app.rule=Host(`${WEDDING_APP_HOST:-wedding.localhost}`)
|
||||||
|
- traefik.http.routers.wedding-app.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||||
|
- traefik.http.routers.wedding-app.tls=true
|
||||||
|
- traefik.http.routers.wedding-app.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}
|
||||||
|
- traefik.http.services.wedding-app.loadbalancer.server.port=3000
|
||||||
|
networks:
|
||||||
|
- wedding-private
|
||||||
|
- traefik-public
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
wedding-db-data:
|
||||||
|
name: wedding-db-data
|
||||||
|
wedding-minio-data:
|
||||||
|
name: wedding-minio-data
|
||||||
|
|
||||||
|
networks:
|
||||||
|
wedding-private:
|
||||||
|
name: wedding-private
|
||||||
|
traefik-public:
|
||||||
|
name: ${TRAEFIK_NETWORK:-traefik}
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function getAdminEmails() {
|
||||||
|
return (process.env.ADMIN_EMAILS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((email) => email.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminEmail(email: string | null | undefined) {
|
||||||
|
if (!email) return false
|
||||||
|
|
||||||
|
return getAdminEmails().includes(email.trim().toLowerCase())
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const booleanStringSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((value) => value.toLowerCase())
|
||||||
|
.pipe(z.enum(["true", "false", "1", "0", "yes", "no"]))
|
||||||
|
.transform((value) => ["true", "1", "yes"].includes(value));
|
||||||
|
|
||||||
|
const serverConfigSchema = z.object({
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
AUTH_SECRET: z.string().min(1),
|
||||||
|
AUTH_GOOGLE_ID: z.string().min(1),
|
||||||
|
AUTH_GOOGLE_SECRET: z.string().min(1),
|
||||||
|
ADMIN_EMAILS: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.transform((value) =>
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((email) => email.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
APP_URL: z.string().url(),
|
||||||
|
RSVP_DEADLINE: z.iso.date(),
|
||||||
|
S3_ENDPOINT: z.string().url(),
|
||||||
|
S3_REGION: z.string().min(1),
|
||||||
|
S3_BUCKET: z.string().min(1),
|
||||||
|
S3_ACCESS_KEY_ID: z.string().min(1),
|
||||||
|
S3_SECRET_ACCESS_KEY: z.string().min(1),
|
||||||
|
S3_FORCE_PATH_STYLE: booleanStringSchema.default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ServerConfig = z.infer<typeof serverConfigSchema>;
|
||||||
|
|
||||||
|
const buildTimeConfigDefaults = {
|
||||||
|
DATABASE_URL: "postgres://wedding:wedding@wedding-db:5432/wedding",
|
||||||
|
AUTH_SECRET: "docker-build-placeholder",
|
||||||
|
AUTH_GOOGLE_ID: "docker-build-placeholder",
|
||||||
|
AUTH_GOOGLE_SECRET: "docker-build-placeholder",
|
||||||
|
ADMIN_EMAILS: "admin@example.com",
|
||||||
|
APP_URL: "https://wedding.example.com",
|
||||||
|
RSVP_DEADLINE: "2099-12-31",
|
||||||
|
S3_ENDPOINT: "http://wedding-minio:9000",
|
||||||
|
S3_REGION: "us-east-1",
|
||||||
|
S3_BUCKET: "wedding-uploads",
|
||||||
|
S3_ACCESS_KEY_ID: "minioadmin",
|
||||||
|
S3_SECRET_ACCESS_KEY: "minioadmin",
|
||||||
|
S3_FORCE_PATH_STYLE: "true",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getServerConfig(env: NodeJS.ProcessEnv = process.env) {
|
||||||
|
if (env.SKIP_ENV_VALIDATION === "1") {
|
||||||
|
return serverConfigSchema.parse({ ...buildTimeConfigDefaults, ...env });
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverConfigSchema.parse(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDatabaseUrl(env: NodeJS.ProcessEnv = process.env) {
|
||||||
|
if (env.SKIP_ENV_VALIDATION === "1") {
|
||||||
|
return buildTimeConfigDefaults.DATABASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return z.string().url().parse(env.DATABASE_URL);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Kysely, PostgresDialect } from "kysely";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
import { getDatabaseUrl } from "@/lib/config";
|
||||||
|
import type { Database } from "@/lib/db/types";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __weddingDb: Kysely<Database> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDatabase() {
|
||||||
|
return new Kysely<Database>({
|
||||||
|
dialect: new PostgresDialect({
|
||||||
|
pool: new Pool({
|
||||||
|
connectionString: getDatabaseUrl(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = globalThis.__weddingDb ?? createDatabase();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalThis.__weddingDb = db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { db } from "@/lib/db/client"
|
||||||
|
import type { ToastRequest } from "@/lib/db/types"
|
||||||
|
import type { InteractionSubmissionInput, ToastStatus } from "@/lib/validation/interactions"
|
||||||
|
|
||||||
|
export interface InvitationInteractions {
|
||||||
|
toastRequests: ToastRequest[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvitationInteractions(
|
||||||
|
invitationId: string,
|
||||||
|
): Promise<InvitationInteractions> {
|
||||||
|
const toastRequests = await db
|
||||||
|
.selectFrom("toast_requests")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "=", invitationId)
|
||||||
|
.orderBy("created_at", "asc")
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
return { toastRequests }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceInvitationInteractions(input: InteractionSubmissionInput) {
|
||||||
|
return db.transaction().execute(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.deleteFrom("toast_requests")
|
||||||
|
.where("invitation_id", "=", input.invitationId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (input.toastRequest) {
|
||||||
|
await trx
|
||||||
|
.insertInto("toast_requests")
|
||||||
|
.values({
|
||||||
|
invitation_id: input.invitationId,
|
||||||
|
member_id: input.toastRequest.memberId,
|
||||||
|
speaker_name: input.toastRequest.speakerName,
|
||||||
|
estimated_minutes: input.toastRequest.estimatedMinutes,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.updateTable("invitations")
|
||||||
|
.set({ updated_at: new Date() })
|
||||||
|
.where("id", "=", input.invitationId)
|
||||||
|
.execute()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateToastRequestStatus(id: string, status: ToastStatus) {
|
||||||
|
await db
|
||||||
|
.updateTable("toast_requests")
|
||||||
|
.set({ status, updated_at: new Date() })
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { nanoid } from "nanoid"
|
||||||
|
|
||||||
|
import { db } from "@/lib/db/client"
|
||||||
|
import type { Invitation, InvitationMember, InvitationResponse } from "@/lib/db/types"
|
||||||
|
import type { CreateInvitationInput } from "@/lib/validation/invitation"
|
||||||
|
import type { SubmitInvitationResponseInput } from "@/lib/validation/response"
|
||||||
|
|
||||||
|
export type DerivedInvitationStatus = "Pending" | "Partially answered" | "Attending" | "Declined" | "Partial"
|
||||||
|
|
||||||
|
export interface InvitationListItem {
|
||||||
|
id: string
|
||||||
|
token: string
|
||||||
|
displayName: string
|
||||||
|
language: string
|
||||||
|
contactEmail: string | null
|
||||||
|
contactPhone: string | null
|
||||||
|
maxPartySize: number
|
||||||
|
allowMemberEdits: boolean
|
||||||
|
membersCount: number
|
||||||
|
attendingCount: number
|
||||||
|
pendingCount: number
|
||||||
|
derivedStatus: DerivedInvitationStatus
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationDetail {
|
||||||
|
invitation: Invitation
|
||||||
|
members: InvitationMember[]
|
||||||
|
response: InvitationResponse | null
|
||||||
|
derivedStatus: DerivedInvitationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveStatus(members: InvitationMember[], submittedAt?: Date | null): DerivedInvitationStatus {
|
||||||
|
if (!submittedAt) return "Pending"
|
||||||
|
|
||||||
|
const pendingCount = members.filter((member) => member.attending === null).length
|
||||||
|
const attendingCount = members.filter((member) => member.attending === true).length
|
||||||
|
const declinedCount = members.filter((member) => member.attending === false).length
|
||||||
|
|
||||||
|
if (pendingCount > 0) return "Partially answered"
|
||||||
|
if (attendingCount > 0 && declinedCount > 0) return "Partial"
|
||||||
|
if (attendingCount > 0) return "Attending"
|
||||||
|
return "Declined"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvitationWithMembers(input: CreateInvitationInput) {
|
||||||
|
return db.transaction().execute(async (trx) => {
|
||||||
|
const invitation = await trx
|
||||||
|
.insertInto("invitations")
|
||||||
|
.values({
|
||||||
|
token: nanoid(32),
|
||||||
|
display_name: input.displayName,
|
||||||
|
language: input.language,
|
||||||
|
contact_email: input.contactEmail,
|
||||||
|
contact_phone: input.contactPhone,
|
||||||
|
max_party_size: input.maxPartySize,
|
||||||
|
allow_member_edits: input.allowMemberEdits,
|
||||||
|
notes: input.notes,
|
||||||
|
})
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insertInto("invitation_members")
|
||||||
|
.values(
|
||||||
|
input.members.map((name, index) => ({
|
||||||
|
invitation_id: invitation.id,
|
||||||
|
name,
|
||||||
|
is_primary_contact: index === 0,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
return invitation
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInvitations(): Promise<InvitationListItem[]> {
|
||||||
|
const invitations = await db
|
||||||
|
.selectFrom("invitations")
|
||||||
|
.selectAll()
|
||||||
|
.orderBy("updated_at", "desc")
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (invitations.length === 0) return []
|
||||||
|
|
||||||
|
const invitationIds = invitations.map((invitation) => invitation.id)
|
||||||
|
|
||||||
|
const [members, responses] = await Promise.all([
|
||||||
|
db
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "in", invitationIds)
|
||||||
|
.execute(),
|
||||||
|
db
|
||||||
|
.selectFrom("invitation_responses")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "in", invitationIds)
|
||||||
|
.execute(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return invitations.map((invitation) => {
|
||||||
|
const invitationMembers = members.filter(
|
||||||
|
(member) => member.invitation_id === invitation.id,
|
||||||
|
)
|
||||||
|
const response = responses.find(
|
||||||
|
(invitationResponse) => invitationResponse.invitation_id === invitation.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: invitation.id,
|
||||||
|
token: invitation.token,
|
||||||
|
displayName: invitation.display_name,
|
||||||
|
language: invitation.language,
|
||||||
|
contactEmail: invitation.contact_email,
|
||||||
|
contactPhone: invitation.contact_phone,
|
||||||
|
maxPartySize: invitation.max_party_size,
|
||||||
|
allowMemberEdits: invitation.allow_member_edits,
|
||||||
|
membersCount: invitationMembers.length,
|
||||||
|
attendingCount: invitationMembers.filter((member) => member.attending === true).length,
|
||||||
|
pendingCount: invitationMembers.filter((member) => member.attending === null).length,
|
||||||
|
derivedStatus: deriveStatus(invitationMembers, response?.submitted_at ?? null),
|
||||||
|
updatedAt: invitation.updated_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvitationDetail(id: string): Promise<InvitationDetail | null> {
|
||||||
|
const invitation = await db
|
||||||
|
.selectFrom("invitations")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!invitation) return null
|
||||||
|
|
||||||
|
const [members, response] = await Promise.all([
|
||||||
|
db
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "=", id)
|
||||||
|
.orderBy("is_primary_contact", "desc")
|
||||||
|
.orderBy("created_at", "asc")
|
||||||
|
.execute(),
|
||||||
|
db
|
||||||
|
.selectFrom("invitation_responses")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "=", id)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
invitation,
|
||||||
|
members,
|
||||||
|
response: response ?? null,
|
||||||
|
derivedStatus: deriveStatus(members, response?.submitted_at ?? null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvitationByToken(token: string): Promise<InvitationDetail | null> {
|
||||||
|
const invitation = await db
|
||||||
|
.selectFrom("invitations")
|
||||||
|
.selectAll()
|
||||||
|
.where("token", "=", token)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!invitation) return null
|
||||||
|
|
||||||
|
const [members, response] = await Promise.all([
|
||||||
|
db
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "=", invitation.id)
|
||||||
|
.orderBy("is_primary_contact", "desc")
|
||||||
|
.orderBy("created_at", "asc")
|
||||||
|
.execute(),
|
||||||
|
db
|
||||||
|
.selectFrom("invitation_responses")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "=", invitation.id)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
invitation,
|
||||||
|
members,
|
||||||
|
response: response ?? null,
|
||||||
|
derivedStatus: deriveStatus(members, response?.submitted_at ?? null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitInvitationResponse(input: SubmitInvitationResponseInput) {
|
||||||
|
return db.transaction().execute(async (trx) => {
|
||||||
|
const invitation = await trx
|
||||||
|
.selectFrom("invitations")
|
||||||
|
.select(["id"])
|
||||||
|
.where("token", "=", input.token)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!invitation) {
|
||||||
|
throw new Error("Invitation not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMembers = await trx
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.select(["id"])
|
||||||
|
.where("invitation_id", "=", invitation.id)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
const existingMemberIds = new Set(existingMembers.map((member) => member.id))
|
||||||
|
|
||||||
|
for (const member of input.members) {
|
||||||
|
if (!existingMemberIds.has(member.memberId)) {
|
||||||
|
throw new Error("Invalid invitation member")
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.updateTable("invitation_members")
|
||||||
|
.set({
|
||||||
|
attending: member.attending,
|
||||||
|
meal_preference: member.attending ? member.mealPreference : null,
|
||||||
|
dietary_restrictions: member.attending ? member.dietaryRestrictions : null,
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.where("id", "=", member.memberId)
|
||||||
|
.where("invitation_id", "=", invitation.id)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insertInto("invitation_responses")
|
||||||
|
.values({
|
||||||
|
invitation_id: invitation.id,
|
||||||
|
submitted_at: new Date(),
|
||||||
|
arrival_date: input.arrivalDate,
|
||||||
|
departure_date: input.departureDate,
|
||||||
|
stay_location: input.stayLocation,
|
||||||
|
needs_transport_help: input.needsTransportHelp,
|
||||||
|
message_to_couple: input.messageToCouple,
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.column("invitation_id").doUpdateSet({
|
||||||
|
submitted_at: new Date(),
|
||||||
|
arrival_date: input.arrivalDate,
|
||||||
|
departure_date: input.departureDate,
|
||||||
|
stay_location: input.stayLocation,
|
||||||
|
needs_transport_help: input.needsTransportHelp,
|
||||||
|
message_to_couple: input.messageToCouple,
|
||||||
|
updated_at: new Date(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.updateTable("invitations")
|
||||||
|
.set({ updated_at: new Date() })
|
||||||
|
.where("id", "=", invitation.id)
|
||||||
|
.execute()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { db } from "@/lib/db/client"
|
||||||
|
|
||||||
|
export async function listInvitationMembers(invitationId: string) {
|
||||||
|
return db
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "=", invitationId)
|
||||||
|
.orderBy("is_primary_contact", "desc")
|
||||||
|
.orderBy("created_at", "asc")
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { db } from "@/lib/db/client"
|
||||||
|
import type { PhotoUpload } from "@/lib/db/types"
|
||||||
|
|
||||||
|
export async function getPhotoUploadByInvitationId(
|
||||||
|
invitationId: string,
|
||||||
|
): Promise<PhotoUpload | null> {
|
||||||
|
return (
|
||||||
|
(await db
|
||||||
|
.selectFrom("photo_uploads")
|
||||||
|
.selectAll()
|
||||||
|
.where("invitation_id", "=", invitationId)
|
||||||
|
.executeTakeFirst()) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertPhotoUpload(input: {
|
||||||
|
invitationId: string
|
||||||
|
bucket: string
|
||||||
|
objectKey: string
|
||||||
|
originalFilename: string
|
||||||
|
contentType: string
|
||||||
|
sizeBytes: number
|
||||||
|
}) {
|
||||||
|
return db
|
||||||
|
.insertInto("photo_uploads")
|
||||||
|
.values({
|
||||||
|
invitation_id: input.invitationId,
|
||||||
|
bucket: input.bucket,
|
||||||
|
object_key: input.objectKey,
|
||||||
|
original_filename: input.originalFilename,
|
||||||
|
content_type: input.contentType,
|
||||||
|
size_bytes: input.sizeBytes,
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.column("invitation_id").doUpdateSet({
|
||||||
|
bucket: input.bucket,
|
||||||
|
object_key: input.objectKey,
|
||||||
|
original_filename: input.originalFilename,
|
||||||
|
content_type: input.contentType,
|
||||||
|
size_bytes: input.sizeBytes,
|
||||||
|
updated_at: new Date(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";
|
||||||
|
|
||||||
|
export type Timestamp = ColumnType<Date, Date | string | undefined, Date | string>;
|
||||||
|
export type NullableTimestamp = ColumnType<
|
||||||
|
Date | null,
|
||||||
|
Date | string | null | undefined,
|
||||||
|
Date | string | null
|
||||||
|
>;
|
||||||
|
export type DateString = ColumnType<string | null, string | null | undefined, string | null>;
|
||||||
|
export type Json = JsonValue;
|
||||||
|
|
||||||
|
export type JsonValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export interface InvitationsTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
token: string;
|
||||||
|
display_name: string;
|
||||||
|
language: string;
|
||||||
|
contact_email: string | null;
|
||||||
|
contact_phone: string | null;
|
||||||
|
max_party_size: number;
|
||||||
|
allow_member_edits: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: Timestamp;
|
||||||
|
updated_at: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationMembersTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
invitation_id: string;
|
||||||
|
name: string;
|
||||||
|
is_primary_contact: boolean;
|
||||||
|
attending: boolean | null;
|
||||||
|
meal_preference: string | null;
|
||||||
|
dietary_restrictions: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: Timestamp;
|
||||||
|
updated_at: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationResponsesTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
invitation_id: string;
|
||||||
|
submitted_at: NullableTimestamp;
|
||||||
|
arrival_date: DateString | null;
|
||||||
|
departure_date: DateString | null;
|
||||||
|
stay_location: string | null;
|
||||||
|
needs_transport_help: boolean | null;
|
||||||
|
message_to_couple: string | null;
|
||||||
|
created_at: Timestamp;
|
||||||
|
updated_at: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoUploadsTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
invitation_id: string;
|
||||||
|
bucket: string;
|
||||||
|
object_key: string;
|
||||||
|
original_filename: string | null;
|
||||||
|
content_type: string | null;
|
||||||
|
size_bytes: number | null;
|
||||||
|
created_at: Timestamp;
|
||||||
|
updated_at: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastRequestsTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
invitation_id: string;
|
||||||
|
member_id: string | null;
|
||||||
|
speaker_name: string;
|
||||||
|
estimated_minutes: number | null;
|
||||||
|
status: string;
|
||||||
|
created_at: Timestamp;
|
||||||
|
updated_at: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAuditEventsTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
admin_email: string;
|
||||||
|
action: string;
|
||||||
|
target_type: string | null;
|
||||||
|
target_id: string | null;
|
||||||
|
metadata: Json | null;
|
||||||
|
created_at: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
invitations: InvitationsTable;
|
||||||
|
invitation_members: InvitationMembersTable;
|
||||||
|
invitation_responses: InvitationResponsesTable;
|
||||||
|
photo_uploads: PhotoUploadsTable;
|
||||||
|
toast_requests: ToastRequestsTable;
|
||||||
|
admin_audit_events: AdminAuditEventsTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Invitation = Selectable<InvitationsTable>;
|
||||||
|
export type NewInvitation = Insertable<InvitationsTable>;
|
||||||
|
export type InvitationUpdate = Updateable<InvitationsTable>;
|
||||||
|
|
||||||
|
export type InvitationMember = Selectable<InvitationMembersTable>;
|
||||||
|
export type NewInvitationMember = Insertable<InvitationMembersTable>;
|
||||||
|
export type InvitationMemberUpdate = Updateable<InvitationMembersTable>;
|
||||||
|
|
||||||
|
export type InvitationResponse = Selectable<InvitationResponsesTable>;
|
||||||
|
export type NewInvitationResponse = Insertable<InvitationResponsesTable>;
|
||||||
|
export type InvitationResponseUpdate = Updateable<InvitationResponsesTable>;
|
||||||
|
|
||||||
|
export type PhotoUpload = Selectable<PhotoUploadsTable>;
|
||||||
|
export type NewPhotoUpload = Insertable<PhotoUploadsTable>;
|
||||||
|
export type PhotoUploadUpdate = Updateable<PhotoUploadsTable>;
|
||||||
|
|
||||||
|
export type ToastRequest = Selectable<ToastRequestsTable>;
|
||||||
|
export type NewToastRequest = Insertable<ToastRequestsTable>;
|
||||||
|
export type ToastRequestUpdate = Updateable<ToastRequestsTable>;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const supportedLanguages = [
|
||||||
|
{ code: "en", label: "English" },
|
||||||
|
{ code: "es", label: "Spanish" },
|
||||||
|
{ code: "de", label: "German" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type SupportedLanguage = (typeof supportedLanguages)[number]["code"]
|
||||||
|
|
||||||
|
export const supportedLanguageCodes = supportedLanguages.map(
|
||||||
|
(language) => language.code,
|
||||||
|
) as [SupportedLanguage, ...SupportedLanguage[]]
|
||||||
|
|
||||||
|
export function getLanguageLabel(code: string) {
|
||||||
|
return supportedLanguages.find((language) => language.code === code)?.label ?? code
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedLanguage(code: string): code is SupportedLanguage {
|
||||||
|
return supportedLanguageCodes.includes(code as SupportedLanguage)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
export const de = {
|
||||||
|
invalid: {
|
||||||
|
title: "Einladung nicht gefunden",
|
||||||
|
description: "Wir konnten diese Einladung nicht finden. Bitte prüfe den Link oder kontaktiere uns.",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Hochzeitseinladung",
|
||||||
|
title: "Wir freuen uns sehr, mit dir zu feiern",
|
||||||
|
greeting: "Hallo, {name}!",
|
||||||
|
description:
|
||||||
|
"Bitte bestätige, wer kommen wird, und teile ein paar Reisedetails, damit wir alles gut planen können.",
|
||||||
|
},
|
||||||
|
deadline: {
|
||||||
|
open: "Du kannst diese Antwort bis zum {date} bearbeiten.",
|
||||||
|
closed: "Die RSVP-Frist ist abgelaufen. Deine Antworten sind jetzt schreibgeschützt.",
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
wedding: "Hochzeitsdetails",
|
||||||
|
rsvp: "RSVP",
|
||||||
|
travel: "Reisedetails",
|
||||||
|
interactions: "Mach es persönlich",
|
||||||
|
},
|
||||||
|
wedding: {
|
||||||
|
dateLabel: "Datum",
|
||||||
|
dateValue: "Das Hochzeitsdatum wird bald ergänzt",
|
||||||
|
locationLabel: "Ort",
|
||||||
|
locationValue: "Details zum Veranstaltungsort folgen bald",
|
||||||
|
accommodationLabel: "Unterkunft",
|
||||||
|
accommodationValue: "Empfehlungen für Unterkünfte in der Nähe werden später ergänzt.",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
attending: "Nimmt teil?",
|
||||||
|
yes: "Ja",
|
||||||
|
no: "Nein",
|
||||||
|
mealPreference: "Essenswunsch",
|
||||||
|
mealPlaceholder: "Essen auswählen",
|
||||||
|
dietaryRestrictions: "Ernährungseinschränkungen",
|
||||||
|
noRestrictions: "Keine Einschränkungen",
|
||||||
|
arrivalDate: "Anreisedatum",
|
||||||
|
departureDate: "Abreisedatum",
|
||||||
|
stayLocation: "Wo übernachtest du?",
|
||||||
|
needsTransportHelp: "Brauchst du Hilfe beim Transport?",
|
||||||
|
messageToCouple: "Nachricht an das Paar",
|
||||||
|
submit: "RSVP speichern",
|
||||||
|
submitted: "Danke — deine RSVP wurde gespeichert.",
|
||||||
|
},
|
||||||
|
meals: {
|
||||||
|
meat: "Fleisch",
|
||||||
|
fish: "Fisch",
|
||||||
|
vegetarian: "Vegetarisch",
|
||||||
|
vegan: "Vegan",
|
||||||
|
kids: "Kindermenü",
|
||||||
|
other: "Andere",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
generic: "Bitte prüfe das Formular und versuche es erneut.",
|
||||||
|
closed: "Die RSVP-Frist ist abgelaufen.",
|
||||||
|
},
|
||||||
|
interactions: {
|
||||||
|
intro: "Diese Frage ist optional und hilft uns, den Ablauf der Feier zu planen.",
|
||||||
|
toastTitle: "Toast oder Rede",
|
||||||
|
toastDescription: "Möchte jemand aus deiner Gruppe ein paar Worte sagen? Das ist optional und wird von uns geplant.",
|
||||||
|
wantsToast: "Jemand möchte sprechen",
|
||||||
|
speakerName: "Name der sprechenden Person",
|
||||||
|
estimatedMinutes: "Ungefähre Minuten",
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: "Foto für die Hochzeitsüberraschung",
|
||||||
|
description: "Lade ein Foto von dir oder deiner Gruppe für eine Hochzeitsaktivität hoch. Du kannst es bis zur Frist ersetzen.",
|
||||||
|
current: "Aktuell hochgeladenes Foto: {filename}",
|
||||||
|
choose: "Bild auswählen",
|
||||||
|
upload: "Foto hochladen",
|
||||||
|
replace: "Foto ersetzen",
|
||||||
|
uploading: "Wird hochgeladen...",
|
||||||
|
success: "Foto erfolgreich hochgeladen.",
|
||||||
|
error: "Das Foto konnte nicht hochgeladen werden. Bitte versuche es erneut.",
|
||||||
|
disabled: "Foto-Uploads sind geschlossen, da die RSVP-Frist abgelaufen ist.",
|
||||||
|
requirements: "JPG, PNG oder WebP. Maximal 10 MB.",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
export const en = {
|
||||||
|
invalid: {
|
||||||
|
title: "Invitation not found",
|
||||||
|
description: "We could not find this invitation. Please check the link or contact us.",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Wedding invitation",
|
||||||
|
title: "We cannot wait to celebrate with you",
|
||||||
|
greeting: "Hello, {name}!",
|
||||||
|
description:
|
||||||
|
"Please confirm who will attend and share a few travel details so we can plan everything with care.",
|
||||||
|
},
|
||||||
|
deadline: {
|
||||||
|
open: "You can edit this response until {date}.",
|
||||||
|
closed: "The RSVP deadline has passed. Your answers are now read-only.",
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
wedding: "Wedding details",
|
||||||
|
rsvp: "RSVP",
|
||||||
|
travel: "Travel details",
|
||||||
|
interactions: "Make it personal",
|
||||||
|
},
|
||||||
|
wedding: {
|
||||||
|
dateLabel: "Date",
|
||||||
|
dateValue: "Wedding date coming soon",
|
||||||
|
locationLabel: "Location",
|
||||||
|
locationValue: "Venue details coming soon",
|
||||||
|
accommodationLabel: "Accommodation",
|
||||||
|
accommodationValue: "Nearby stay recommendations will be added later.",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
attending: "Attending?",
|
||||||
|
yes: "Yes",
|
||||||
|
no: "No",
|
||||||
|
mealPreference: "Meal preference",
|
||||||
|
mealPlaceholder: "Select a meal",
|
||||||
|
dietaryRestrictions: "Dietary restrictions",
|
||||||
|
noRestrictions: "No restrictions",
|
||||||
|
arrivalDate: "Arrival date",
|
||||||
|
departureDate: "Departure date",
|
||||||
|
stayLocation: "Where are you staying?",
|
||||||
|
needsTransportHelp: "Do you need transportation help?",
|
||||||
|
messageToCouple: "Message to the couple",
|
||||||
|
submit: "Save RSVP",
|
||||||
|
submitted: "Thank you — your RSVP was saved.",
|
||||||
|
},
|
||||||
|
meals: {
|
||||||
|
meat: "Meat",
|
||||||
|
fish: "Fish",
|
||||||
|
vegetarian: "Vegetarian",
|
||||||
|
vegan: "Vegan",
|
||||||
|
kids: "Kids menu",
|
||||||
|
other: "Other",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
generic: "Please check the RSVP form and try again.",
|
||||||
|
closed: "The RSVP deadline has passed.",
|
||||||
|
},
|
||||||
|
interactions: {
|
||||||
|
intro: "This question is optional and helps us plan the celebration schedule.",
|
||||||
|
toastTitle: "Toast or speech",
|
||||||
|
toastDescription: "Would someone in your group like to say a few words? This is optional and will be scheduled by us.",
|
||||||
|
wantsToast: "Someone would like to speak",
|
||||||
|
speakerName: "Speaker name",
|
||||||
|
estimatedMinutes: "Estimated minutes",
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: "Wedding surprise photo",
|
||||||
|
description: "Upload one photo of yourself or your group for a wedding activity. You can replace it until the deadline.",
|
||||||
|
current: "Current photo uploaded: {filename}",
|
||||||
|
choose: "Choose image",
|
||||||
|
upload: "Upload photo",
|
||||||
|
replace: "Replace photo",
|
||||||
|
uploading: "Uploading...",
|
||||||
|
success: "Photo uploaded successfully.",
|
||||||
|
error: "We could not upload the photo. Please try again.",
|
||||||
|
disabled: "Photo uploads are closed because the RSVP deadline has passed.",
|
||||||
|
requirements: "JPG, PNG, or WebP. Maximum 10 MB.",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
export const es = {
|
||||||
|
invalid: {
|
||||||
|
title: "Invitación no encontrada",
|
||||||
|
description: "No pudimos encontrar esta invitación. Revisa el enlace o contáctanos.",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Invitación de boda",
|
||||||
|
title: "Nos emociona mucho celebrar contigo",
|
||||||
|
greeting: "¡Hola, {name}!",
|
||||||
|
description:
|
||||||
|
"Confirma quién asistirá y comparte algunos detalles del viaje para que podamos planificar todo con cariño.",
|
||||||
|
},
|
||||||
|
deadline: {
|
||||||
|
open: "Puedes editar esta respuesta hasta el {date}.",
|
||||||
|
closed: "La fecha límite para confirmar asistencia ya pasó. Tus respuestas están en modo lectura.",
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
wedding: "Detalles de la boda",
|
||||||
|
rsvp: "Confirmación",
|
||||||
|
travel: "Detalles del viaje",
|
||||||
|
interactions: "Hazlo personal",
|
||||||
|
},
|
||||||
|
wedding: {
|
||||||
|
dateLabel: "Fecha",
|
||||||
|
dateValue: "La fecha de la boda se agregará pronto",
|
||||||
|
locationLabel: "Lugar",
|
||||||
|
locationValue: "Los detalles del lugar se agregarán pronto",
|
||||||
|
accommodationLabel: "Alojamiento",
|
||||||
|
accommodationValue: "Pronto agregaremos recomendaciones para quedarse cerca.",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
attending: "¿Asiste?",
|
||||||
|
yes: "Sí",
|
||||||
|
no: "No",
|
||||||
|
mealPreference: "Preferencia de comida",
|
||||||
|
mealPlaceholder: "Selecciona una opción",
|
||||||
|
dietaryRestrictions: "Restricciones alimentarias",
|
||||||
|
noRestrictions: "Sin restricciones",
|
||||||
|
arrivalDate: "Fecha de llegada",
|
||||||
|
departureDate: "Fecha de salida",
|
||||||
|
stayLocation: "¿Dónde te hospedarás?",
|
||||||
|
needsTransportHelp: "¿Necesitas ayuda con transporte?",
|
||||||
|
messageToCouple: "Mensaje para la pareja",
|
||||||
|
submit: "Guardar confirmación",
|
||||||
|
submitted: "Gracias — guardamos tu confirmación.",
|
||||||
|
},
|
||||||
|
meals: {
|
||||||
|
meat: "Carne",
|
||||||
|
fish: "Pescado",
|
||||||
|
vegetarian: "Vegetariano",
|
||||||
|
vegan: "Vegano",
|
||||||
|
kids: "Menú infantil",
|
||||||
|
other: "Otro",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
generic: "Revisa el formulario e inténtalo de nuevo.",
|
||||||
|
closed: "La fecha límite para confirmar asistencia ya pasó.",
|
||||||
|
},
|
||||||
|
interactions: {
|
||||||
|
intro: "Esta pregunta es opcional y nos ayuda a organizar el horario de la celebración.",
|
||||||
|
toastTitle: "Brindis o discurso",
|
||||||
|
toastDescription: "¿Alguien de tu grupo quiere decir unas palabras? Es opcional y nosotros lo organizaremos.",
|
||||||
|
wantsToast: "Alguien quiere hablar",
|
||||||
|
speakerName: "Nombre de la persona",
|
||||||
|
estimatedMinutes: "Minutos aproximados",
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: "Foto para la sorpresa de la boda",
|
||||||
|
description: "Sube una foto tuya o de tu grupo para una actividad de la boda. Puedes reemplazarla hasta la fecha límite.",
|
||||||
|
current: "Foto actual subida: {filename}",
|
||||||
|
choose: "Elegir imagen",
|
||||||
|
upload: "Subir foto",
|
||||||
|
replace: "Reemplazar foto",
|
||||||
|
uploading: "Subiendo...",
|
||||||
|
success: "Foto subida correctamente.",
|
||||||
|
error: "No pudimos subir la foto. Inténtalo de nuevo.",
|
||||||
|
disabled: "La subida de fotos está cerrada porque ya pasó la fecha límite.",
|
||||||
|
requirements: "JPG, PNG o WebP. Máximo 10 MB.",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { de } from "./de"
|
||||||
|
import { en } from "./en"
|
||||||
|
import { es } from "./es"
|
||||||
|
|
||||||
|
export const dictionaries = {
|
||||||
|
en,
|
||||||
|
es,
|
||||||
|
de,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type GuestDictionary = typeof en
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { dictionaries } from "@/lib/i18n/dictionaries"
|
||||||
|
import { isSupportedLanguage } from "@/lib/i18n/config"
|
||||||
|
|
||||||
|
export function getGuestDictionary(language: string) {
|
||||||
|
if (isSupportedLanguage(language)) {
|
||||||
|
return dictionaries[language]
|
||||||
|
}
|
||||||
|
|
||||||
|
return dictionaries.en
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function getRsvpDeadline() {
|
||||||
|
const value = process.env.RSVP_DEADLINE
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRsvpClosed(now = new Date()) {
|
||||||
|
const deadline = getRsvpDeadline()
|
||||||
|
if (!deadline) return false
|
||||||
|
|
||||||
|
const deadlineEnd = new Date(`${deadline}T23:59:59.999Z`)
|
||||||
|
return now > deadlineEnd
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { S3Client } from "@aws-sdk/client-s3"
|
||||||
|
|
||||||
|
import { getServerConfig } from "@/lib/config"
|
||||||
|
|
||||||
|
export function getS3Client() {
|
||||||
|
const config = getServerConfig()
|
||||||
|
|
||||||
|
return new S3Client({
|
||||||
|
endpoint: config.S3_ENDPOINT,
|
||||||
|
region: config.S3_REGION,
|
||||||
|
forcePathStyle: config.S3_FORCE_PATH_STYLE,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.S3_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: config.S3_SECRET_ACCESS_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUploadBucket() {
|
||||||
|
return getServerConfig().S3_BUCKET
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
|
||||||
|
import { getS3Client, getUploadBucket } from "@/lib/storage/s3"
|
||||||
|
|
||||||
|
function safeFilename(filename: string) {
|
||||||
|
const normalized = filename
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
|
||||||
|
return normalized || "upload"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInvitationUploadKey(invitationId: string, filename: string) {
|
||||||
|
return `invitations/${invitationId}/${nanoid(12)}-${safeFilename(filename)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isObjectKeyForInvitation(objectKey: string, invitationId: string) {
|
||||||
|
return objectKey.startsWith(`invitations/${invitationId}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPresignedUploadUrl({
|
||||||
|
objectKey,
|
||||||
|
contentType,
|
||||||
|
sizeBytes,
|
||||||
|
}: {
|
||||||
|
objectKey: string
|
||||||
|
contentType: string
|
||||||
|
sizeBytes: number
|
||||||
|
}) {
|
||||||
|
const client = getS3Client()
|
||||||
|
const bucket = getUploadBucket()
|
||||||
|
|
||||||
|
return getSignedUrl(
|
||||||
|
client,
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: objectKey,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: sizeBytes,
|
||||||
|
}),
|
||||||
|
{ expiresIn: 60 * 5 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPresignedDownloadUrl(objectKey: string) {
|
||||||
|
const client = getS3Client()
|
||||||
|
const bucket = getUploadBucket()
|
||||||
|
|
||||||
|
return getSignedUrl(
|
||||||
|
client,
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: objectKey,
|
||||||
|
}),
|
||||||
|
{ expiresIn: 60 * 10 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUploadObject(objectKey: string) {
|
||||||
|
const client = getS3Client()
|
||||||
|
const bucket = getUploadBucket()
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: objectKey,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const optionalPositiveInteger = z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (typeof value !== "string") return null
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : null
|
||||||
|
},
|
||||||
|
z.coerce.number().int().positive().nullable(),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const toastRequestSchema = z.object({
|
||||||
|
memberId: z.uuid().nullable(),
|
||||||
|
speakerName: z.string().trim().min(1).max(160),
|
||||||
|
estimatedMinutes: optionalPositiveInteger,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const interactionSubmissionSchema = z.object({
|
||||||
|
invitationId: z.uuid(),
|
||||||
|
toastRequest: toastRequestSchema.nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toastStatusSchema = z.enum(["pending", "accepted", "declined", "scheduled"])
|
||||||
|
|
||||||
|
export type InteractionSubmissionInput = z.infer<typeof interactionSubmissionSchema>
|
||||||
|
export type ToastStatus = z.infer<typeof toastStatusSchema>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { supportedLanguageCodes } from "@/lib/i18n/config"
|
||||||
|
|
||||||
|
const optionalText = z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (typeof value !== "string") return null
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : null
|
||||||
|
},
|
||||||
|
z.string().nullable(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const optionalEmail = z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (typeof value !== "string") return null
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : null
|
||||||
|
},
|
||||||
|
z.email().nullable(),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createInvitationSchema = z
|
||||||
|
.object({
|
||||||
|
displayName: z.string().trim().min(1, "Display name is required"),
|
||||||
|
language: z.enum(supportedLanguageCodes, {
|
||||||
|
error: "Select a supported language",
|
||||||
|
}),
|
||||||
|
contactEmail: optionalEmail.optional().default(null),
|
||||||
|
contactPhone: optionalText.optional().default(null),
|
||||||
|
maxPartySize: z.coerce
|
||||||
|
.number()
|
||||||
|
.int("Party size must be a whole number")
|
||||||
|
.min(1, "Party size must be at least 1"),
|
||||||
|
allowMemberEdits: z.coerce.boolean().default(false),
|
||||||
|
notes: optionalText.optional().default(null),
|
||||||
|
members: z
|
||||||
|
.array(z.string().trim().min(1))
|
||||||
|
.min(1, "Add at least one invitation member"),
|
||||||
|
})
|
||||||
|
.refine((value) => value.members.length <= value.maxPartySize, {
|
||||||
|
message: "Members cannot exceed max party size",
|
||||||
|
path: ["members"],
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const mealPreferenceSchema = z.enum([
|
||||||
|
"meat",
|
||||||
|
"fish",
|
||||||
|
"vegetarian",
|
||||||
|
"vegan",
|
||||||
|
"kids",
|
||||||
|
"other",
|
||||||
|
])
|
||||||
|
|
||||||
|
const optionalText = z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (typeof value !== "string") return null
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : null
|
||||||
|
},
|
||||||
|
z.string().nullable(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const optionalDate = z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (typeof value !== "string") return null
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : null
|
||||||
|
},
|
||||||
|
z.iso.date().nullable(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const memberResponseSchema = z
|
||||||
|
.object({
|
||||||
|
memberId: z.uuid(),
|
||||||
|
attending: z.boolean(),
|
||||||
|
mealPreference: mealPreferenceSchema.nullable(),
|
||||||
|
dietaryRestrictions: optionalText,
|
||||||
|
})
|
||||||
|
.refine((value) => !value.attending || value.mealPreference !== null, {
|
||||||
|
message: "Meal preference is required for attending guests",
|
||||||
|
path: ["mealPreference"],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const submitInvitationResponseSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
members: z.array(memberResponseSchema).min(1),
|
||||||
|
arrivalDate: optionalDate,
|
||||||
|
departureDate: optionalDate,
|
||||||
|
stayLocation: optionalText,
|
||||||
|
needsTransportHelp: z.boolean().nullable(),
|
||||||
|
messageToCouple: optionalText,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SubmitInvitationResponseInput = z.infer<typeof submitInvitationResponseSchema>
|
||||||
|
export type MealPreference = z.infer<typeof mealPreferenceSchema>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const allowedImageContentTypes = ["image/jpeg", "image/png", "image/webp"] as const
|
||||||
|
export const maxUploadSizeBytes = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
export const presignUploadSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
filename: z.string().trim().min(1).max(255),
|
||||||
|
contentType: z.enum(allowedImageContentTypes),
|
||||||
|
sizeBytes: z.number().int().positive().max(maxUploadSizeBytes),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const completeUploadSchema = presignUploadSchema.extend({
|
||||||
|
objectKey: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PresignUploadInput = z.infer<typeof presignUploadSchema>
|
||||||
|
export type CompleteUploadInput = z.infer<typeof completeUploadSchema>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
-- Wedding website bootstrap schema
|
||||||
|
-- Requires: PostgreSQL 13+
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invitations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token text NOT NULL,
|
||||||
|
display_name text NOT NULL,
|
||||||
|
language text NOT NULL,
|
||||||
|
contact_email text NULL,
|
||||||
|
contact_phone text NULL,
|
||||||
|
max_party_size integer NOT NULL DEFAULT 1,
|
||||||
|
allow_member_edits boolean NOT NULL DEFAULT false,
|
||||||
|
notes text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT invitations_max_party_size_positive CHECK (max_party_size > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique ON invitations (token);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invitation_members (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invitation_id uuid NOT NULL REFERENCES invitations(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
is_primary_contact boolean NOT NULL DEFAULT false,
|
||||||
|
attending boolean NULL,
|
||||||
|
meal_preference text NULL,
|
||||||
|
dietary_restrictions text NULL,
|
||||||
|
notes text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS invitation_members_invitation_id_idx
|
||||||
|
ON invitation_members (invitation_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invitation_responses (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invitation_id uuid NOT NULL REFERENCES invitations(id) ON DELETE CASCADE,
|
||||||
|
submitted_at timestamptz NULL,
|
||||||
|
arrival_date date NULL,
|
||||||
|
departure_date date NULL,
|
||||||
|
stay_location text NULL,
|
||||||
|
coming_from text NULL,
|
||||||
|
needs_transport_help boolean NULL,
|
||||||
|
message_to_couple text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS invitation_responses_invitation_id_unique
|
||||||
|
ON invitation_responses (invitation_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS photo_uploads (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invitation_id uuid NOT NULL REFERENCES invitations(id) ON DELETE CASCADE,
|
||||||
|
bucket text NOT NULL,
|
||||||
|
object_key text NOT NULL,
|
||||||
|
original_filename text NULL,
|
||||||
|
content_type text NULL,
|
||||||
|
size_bytes integer NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT photo_uploads_size_bytes_non_negative
|
||||||
|
CHECK (size_bytes IS NULL OR size_bytes >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS photo_uploads_invitation_id_unique
|
||||||
|
ON photo_uploads (invitation_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS song_requests (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invitation_id uuid NOT NULL REFERENCES invitations(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
artist text NULL,
|
||||||
|
note text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS song_requests_invitation_id_idx
|
||||||
|
ON song_requests (invitation_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS toast_requests (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invitation_id uuid NOT NULL REFERENCES invitations(id) ON DELETE CASCADE,
|
||||||
|
member_id uuid NULL REFERENCES invitation_members(id) ON DELETE SET NULL,
|
||||||
|
speaker_name text NOT NULL,
|
||||||
|
estimated_minutes integer NULL,
|
||||||
|
note text NULL,
|
||||||
|
status text NOT NULL DEFAULT 'pending',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT toast_requests_status_valid
|
||||||
|
CHECK (status IN ('pending', 'accepted', 'declined', 'scheduled')),
|
||||||
|
CONSTRAINT toast_requests_estimated_minutes_positive
|
||||||
|
CHECK (estimated_minutes IS NULL OR estimated_minutes > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS toast_requests_invitation_id_idx
|
||||||
|
ON toast_requests (invitation_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS guest_messages (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invitation_id uuid NOT NULL REFERENCES invitations(id) ON DELETE CASCADE,
|
||||||
|
type text NOT NULL,
|
||||||
|
message text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT guest_messages_type_valid CHECK (type IN ('advice', 'memory', 'wish'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS guest_messages_invitation_id_idx
|
||||||
|
ON guest_messages (invitation_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_audit_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
admin_email text NOT NULL,
|
||||||
|
action text NOT NULL,
|
||||||
|
target_type text NULL,
|
||||||
|
target_id uuid NULL,
|
||||||
|
metadata jsonb NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Remove guest-facing fields that are no longer collected.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS song_requests;
|
||||||
|
DROP TABLE IF EXISTS guest_messages;
|
||||||
|
|
||||||
|
ALTER TABLE invitation_responses
|
||||||
|
DROP COLUMN IF EXISTS coming_from;
|
||||||
|
|
||||||
|
ALTER TABLE toast_requests
|
||||||
|
DROP COLUMN IF EXISTS note;
|
||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+3269
-10
File diff suppressed because it is too large
Load Diff
+18
-2
@@ -6,20 +6,36 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:migrate": "node scripts/migrate-sql.mjs",
|
||||||
|
"db:generate": "kysely-codegen --env-file .env --dialect postgres --out-file lib/db/types.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.940.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"kysely": "^0.28.8",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"radix-ui": "^1.5.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
|
"kysely-codegen": "^0.19.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Milestone 01 — Infrastructure and Database Foundation
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Read `plans/wedding-bootstrap-plan.md` first. It is the source of truth for architecture, naming, schema intent, and constraints.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Create the local/runtime foundation for the app: dependencies, Docker Compose services, environment template, Kysely database client, SQL migration script, and initial schema.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implement only:
|
||||||
|
|
||||||
|
- Required dependencies for PostgreSQL, Kysely, migrations, validation, auth/upload placeholders where needed.
|
||||||
|
- `docker-compose.yml` with PostgreSQL and MinIO services, plus MinIO bucket initialization.
|
||||||
|
- `.env.example` with all required bootstrap variables.
|
||||||
|
- Typed config helper.
|
||||||
|
- Kysely PostgreSQL client.
|
||||||
|
- Node `.mjs` SQL migration runner under `scripts/`.
|
||||||
|
- Initial pure SQL migration for the core schema.
|
||||||
|
- Initial database typings, either hand-written or generated if practical.
|
||||||
|
- Package scripts needed to run migrations.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- Google auth.
|
||||||
|
- `/admin` pages.
|
||||||
|
- shadcn UI setup unless strictly required by project initialization.
|
||||||
|
- Guest RSVP pages.
|
||||||
|
- MinIO upload endpoints.
|
||||||
|
- Presigned upload logic.
|
||||||
|
- Song/toast/message forms.
|
||||||
|
- Final visual design.
|
||||||
|
|
||||||
|
## Required constraints
|
||||||
|
|
||||||
|
- Use PostgreSQL.
|
||||||
|
- Use Kysely. Do not add Prisma, Drizzle, TypeORM, Sequelize, or another ORM.
|
||||||
|
- Keep SQL schema aligned with `wedding-bootstrap-plan.md`.
|
||||||
|
- `invitations.language` is required from the first migration.
|
||||||
|
- `invitation_members.attending` is the source of truth for attendance.
|
||||||
|
- Do not duplicate attendance status on `invitation_responses`.
|
||||||
|
- `photo_uploads.invitation_id` must be unique to enforce one photo per invitation.
|
||||||
|
|
||||||
|
## Implementation tasks
|
||||||
|
|
||||||
|
- Add dependencies:
|
||||||
|
- `kysely`
|
||||||
|
- `pg`
|
||||||
|
- `zod`
|
||||||
|
- `nanoid`
|
||||||
|
- AWS S3 packages if useful to install now
|
||||||
|
- Add dev dependencies:
|
||||||
|
- `@types/pg`
|
||||||
|
- `kysely-codegen` if used
|
||||||
|
- Add `.env.example`.
|
||||||
|
- Add `docker-compose.yml` with:
|
||||||
|
- `postgres`
|
||||||
|
- `minio`
|
||||||
|
- `minio-init`
|
||||||
|
- Add `lib/config.ts` for server-side env parsing.
|
||||||
|
- Add `lib/db/client.ts`.
|
||||||
|
- Add `scripts/migrate-sql.mjs`.
|
||||||
|
- Add `migrations/0001_initial_schema.sql`.
|
||||||
|
- Add `lib/db/types.ts`.
|
||||||
|
- Add package scripts:
|
||||||
|
- `db:migrate`
|
||||||
|
- optionally `db:generate` if using `kysely-codegen`
|
||||||
|
|
||||||
|
## Initial tables
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
- `invitations`
|
||||||
|
- `invitation_members`
|
||||||
|
- `invitation_responses`
|
||||||
|
- `photo_uploads`
|
||||||
|
- `song_requests`
|
||||||
|
- `toast_requests`
|
||||||
|
- `guest_messages`
|
||||||
|
- optionally `admin_audit_events`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres minio minio-init
|
||||||
|
npm run db:migrate
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
If Docker is unavailable in the execution environment, still implement the files and clearly report that Docker verification could not be run.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Docker Compose defines working PostgreSQL and MinIO services.
|
||||||
|
- `.env.example` documents all required variables.
|
||||||
|
- Migrations create the planned schema.
|
||||||
|
- The Node `.mjs` migration script can run pure SQL files against PostgreSQL.
|
||||||
|
- The app still passes lint/build.
|
||||||
|
- No future milestone features were implemented.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Milestone 02 — Google Auth and Admin Shell
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Read `plans/wedding-bootstrap-plan.md` first. Also confirm the current Next.js/Auth.js setup before implementation because this project uses Next.js 16.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add Google login restricted to the couple and create a protected `/admin` shell using shadcn/ui components only.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implement only:
|
||||||
|
|
||||||
|
- Google OAuth authentication.
|
||||||
|
- Admin email allowlist via `ADMIN_EMAILS`.
|
||||||
|
- Route protection for `/admin` and `/admin/*`.
|
||||||
|
- Basic `/admin` dashboard shell.
|
||||||
|
- Sign-in/sign-out UI.
|
||||||
|
- shadcn/ui setup and required admin shell components.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- Invitation creation forms.
|
||||||
|
- Invitation list/detail functionality.
|
||||||
|
- Guest invite pages.
|
||||||
|
- RSVP submission.
|
||||||
|
- MinIO uploads.
|
||||||
|
- Song/toast/message workflows.
|
||||||
|
|
||||||
|
## Required constraints
|
||||||
|
|
||||||
|
- `/admin` and `/admin/*` require authenticated admin access.
|
||||||
|
- Only emails in `ADMIN_EMAILS` can access admin pages.
|
||||||
|
- Guest pages must not require login.
|
||||||
|
- Admin UI must use shadcn/ui components only.
|
||||||
|
- Do not introduce another admin component library.
|
||||||
|
- If a component is needed, add it through the shadcn workflow.
|
||||||
|
|
||||||
|
## Implementation tasks
|
||||||
|
|
||||||
|
- Add/configure Auth.js/NextAuth Google provider.
|
||||||
|
- Add `auth.ts` or equivalent auth config.
|
||||||
|
- Add `app/api/auth/[...nextauth]/route.ts` or current-version equivalent.
|
||||||
|
- Add middleware or route-level protection for `/admin`.
|
||||||
|
- Add forbidden/unauthorized handling.
|
||||||
|
- Initialize shadcn/ui if not already initialized.
|
||||||
|
- Add required shadcn components for the admin shell, likely:
|
||||||
|
- button
|
||||||
|
- card
|
||||||
|
- dropdown-menu
|
||||||
|
- avatar or badge if useful
|
||||||
|
- alert
|
||||||
|
- Create `/admin` page with basic dashboard placeholder cards.
|
||||||
|
- Add sign-in/sign-out controls.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual checks:
|
||||||
|
|
||||||
|
- Unauthenticated user cannot access `/admin`.
|
||||||
|
- Allowed Google email can access `/admin`.
|
||||||
|
- Non-allowlisted Google email is denied.
|
||||||
|
- Public/guest routes remain accessible without login.
|
||||||
|
- Admin page uses only shadcn/ui components.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Admin auth works with Google OAuth and email allowlist.
|
||||||
|
- `/admin` is protected.
|
||||||
|
- Admin shell exists and builds.
|
||||||
|
- No non-shadcn admin UI component library was added.
|
||||||
|
- No future milestone features were implemented.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Milestone 03 — Admin Invitation Management
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Read `plans/wedding-bootstrap-plan.md` first. This milestone depends on Milestone 01 and Milestone 02 being complete.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow admins to create, list, and view invitations and invitation members from the protected admin portal.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implement only:
|
||||||
|
|
||||||
|
- Invitation query modules.
|
||||||
|
- Invitation member query modules.
|
||||||
|
- Admin invitation list page.
|
||||||
|
- Admin create invitation page.
|
||||||
|
- Admin invitation detail page.
|
||||||
|
- Required shadcn/ui admin components.
|
||||||
|
- Copy invite link affordance.
|
||||||
|
- Language selection per invitation.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- Guest RSVP submission.
|
||||||
|
- Guest-facing localized page.
|
||||||
|
- MinIO uploads.
|
||||||
|
- Song/toast/message guest forms.
|
||||||
|
- CSV export unless trivial and explicitly requested later.
|
||||||
|
- Final design polish.
|
||||||
|
|
||||||
|
## Required constraints
|
||||||
|
|
||||||
|
- Admin UI must use shadcn/ui components only.
|
||||||
|
- The admin create/edit form must require `language`.
|
||||||
|
- Use the invitation/member model from the main plan.
|
||||||
|
- Generate unguessable invitation tokens.
|
||||||
|
- Use Kysely query modules; do not add an ORM.
|
||||||
|
- Keep routes protected by admin auth.
|
||||||
|
|
||||||
|
## Implementation tasks
|
||||||
|
|
||||||
|
- Add query helpers for:
|
||||||
|
- create invitation with members
|
||||||
|
- list invitations with derived counts/status
|
||||||
|
- get invitation detail by id
|
||||||
|
- update invitation metadata if included
|
||||||
|
- add/update/remove members if included
|
||||||
|
- Add validation schema for invitation creation.
|
||||||
|
- Add supported language config if not already present.
|
||||||
|
- Add `/admin/guests` page.
|
||||||
|
- Add `/admin/guests/new` page.
|
||||||
|
- Add `/admin/guests/[id]` page.
|
||||||
|
- Use shadcn components for:
|
||||||
|
- table
|
||||||
|
- form
|
||||||
|
- input
|
||||||
|
- select
|
||||||
|
- checkbox
|
||||||
|
- button
|
||||||
|
- card
|
||||||
|
- badge
|
||||||
|
- dropdown/action menu if needed
|
||||||
|
- alert/toast if needed
|
||||||
|
|
||||||
|
## Admin fields
|
||||||
|
|
||||||
|
Create invitation form must include:
|
||||||
|
|
||||||
|
- display name
|
||||||
|
- language
|
||||||
|
- contact email
|
||||||
|
- contact phone
|
||||||
|
- max party size
|
||||||
|
- allow member edits
|
||||||
|
- notes
|
||||||
|
- initial members
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual checks:
|
||||||
|
|
||||||
|
- Admin can create an invitation with multiple members.
|
||||||
|
- Language is required and saved.
|
||||||
|
- Invitation token is generated.
|
||||||
|
- Invite link can be copied or displayed.
|
||||||
|
- Invitation appears in list.
|
||||||
|
- Invitation detail page displays members and metadata.
|
||||||
|
- Non-admin cannot access these pages.
|
||||||
|
- Admin pages use shadcn/ui only.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Admin can manage the initial invitation/member data needed for guest RSVP.
|
||||||
|
- Invitation language exists in the UI and database.
|
||||||
|
- The implementation does not include guest RSVP or uploads yet.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Milestone 04 — Guest RSVP and Invitation Language
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Read `plans/wedding-bootstrap-plan.md` first. This milestone depends on Milestones 01 and 03. Admin auth from Milestone 02 should already protect admin pages.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build the guest-facing `/invite/[token]` flow with localized copy based on `invitations.language`, per-member attendance, per-member meal/dietary fields, group logistics, and deadline behavior.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implement only:
|
||||||
|
|
||||||
|
- `/invite/[token]` page.
|
||||||
|
- Invalid invitation handling.
|
||||||
|
- Localization dictionary foundation for guest-facing copy.
|
||||||
|
- Per-member RSVP form.
|
||||||
|
- Group logistics form.
|
||||||
|
- Save/update `invitation_members` attendance/meal/dietary fields.
|
||||||
|
- Save/update `invitation_responses` logistics and `submitted_at`.
|
||||||
|
- RSVP deadline read-only behavior.
|
||||||
|
- Admin visibility of submitted RSVP data if not already present.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- MinIO photo upload.
|
||||||
|
- Song requests.
|
||||||
|
- Toast requests.
|
||||||
|
- Guest messages.
|
||||||
|
- Advanced final visual design.
|
||||||
|
- New admin component libraries.
|
||||||
|
|
||||||
|
## Required constraints
|
||||||
|
|
||||||
|
- `invitations.language` is the source of truth for guest-facing language.
|
||||||
|
- Do not rely only on browser language detection.
|
||||||
|
- `invitation_members.attending` is the source of truth for attendance.
|
||||||
|
- Do not store duplicate attendance status on `invitation_responses`.
|
||||||
|
- Edits are allowed until `RSVP_DEADLINE`.
|
||||||
|
- After the deadline, guest RSVP data is read-only.
|
||||||
|
- Guest-facing design may be custom; shadcn-only restriction applies to admin pages.
|
||||||
|
|
||||||
|
## Implementation tasks
|
||||||
|
|
||||||
|
- Add `lib/i18n/config.ts` with supported languages.
|
||||||
|
- Add translation dictionaries under `lib/i18n/dictionaries/`.
|
||||||
|
- Add helper to resolve dictionary by invitation language.
|
||||||
|
- Add response/member validation schemas.
|
||||||
|
- Add query helpers for:
|
||||||
|
- find invitation by token with members/response
|
||||||
|
- update member RSVP fields
|
||||||
|
- upsert invitation response
|
||||||
|
- Implement `/invite/[token]`.
|
||||||
|
- Add form handling using current Next.js best practice.
|
||||||
|
- Add deadline helper.
|
||||||
|
- Ensure admin detail/list can show derived RSVP status and submitted response data.
|
||||||
|
|
||||||
|
## Localized guest copy must include
|
||||||
|
|
||||||
|
- Page headings.
|
||||||
|
- RSVP labels.
|
||||||
|
- Meal/dietary labels.
|
||||||
|
- Travel/logistics labels.
|
||||||
|
- Validation messages.
|
||||||
|
- Submit/success/error messages.
|
||||||
|
- Deadline/read-only messages.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual checks:
|
||||||
|
|
||||||
|
- Invalid token shows a friendly invalid invitation page.
|
||||||
|
- Valid token shows personalized invitation.
|
||||||
|
- Page renders using the invitation's configured language.
|
||||||
|
- Different members can have different attendance values.
|
||||||
|
- Different members can have different meal preferences/dietary restrictions.
|
||||||
|
- Group logistics save correctly.
|
||||||
|
- `submitted_at` is set after submission.
|
||||||
|
- Admin can see submitted RSVP data.
|
||||||
|
- After deadline, guest form is read-only.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Guest RSVP MVP works end-to-end.
|
||||||
|
- Localization is driven by invitation language.
|
||||||
|
- Attendance is stored only per member.
|
||||||
|
- No upload or optional interaction features were implemented yet.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Milestone 05 — Optional Guest Interactions
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Read `plans/wedding-bootstrap-plan.md` first. This milestone depends on Milestone 04.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add optional guest interaction sections that make the wedding experience more personal: song requests, toast/speech requests, and advice/wish/memory messages.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implement only:
|
||||||
|
|
||||||
|
- Guest song request form section.
|
||||||
|
- Guest toast/speech request form section.
|
||||||
|
- Guest advice/wish/memory message form section.
|
||||||
|
- Query modules for these tables.
|
||||||
|
- Validation schemas.
|
||||||
|
- Admin display and basic management for these submissions.
|
||||||
|
- Localization copy for these sections.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- MinIO photo upload.
|
||||||
|
- Presigned URLs.
|
||||||
|
- CSV export unless explicitly requested later.
|
||||||
|
- Complex scheduling UI for toasts.
|
||||||
|
- Final design polish.
|
||||||
|
|
||||||
|
## Required constraints
|
||||||
|
|
||||||
|
- Guest-facing copy must use `invitations.language`.
|
||||||
|
- Admin UI must use shadcn/ui components only.
|
||||||
|
- Use Kysely query modules; do not add an ORM.
|
||||||
|
- Song requests should be limited, recommended max: 3 per invitation.
|
||||||
|
- Toast requests are voluntary and admin-approved.
|
||||||
|
|
||||||
|
## Implementation tasks
|
||||||
|
|
||||||
|
- Add query helpers for:
|
||||||
|
- song requests
|
||||||
|
- toast requests
|
||||||
|
- guest messages
|
||||||
|
- Add Zod schemas for interactions.
|
||||||
|
- Add localized copy for interaction sections.
|
||||||
|
- Add guest-facing form sections to `/invite/[token]`.
|
||||||
|
- Persist submissions.
|
||||||
|
- Add admin views on invitation detail page for:
|
||||||
|
- song requests
|
||||||
|
- toast requests
|
||||||
|
- guest messages
|
||||||
|
- Add toast status controls if feasible:
|
||||||
|
- pending
|
||||||
|
- accepted
|
||||||
|
- declined
|
||||||
|
- scheduled
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual checks:
|
||||||
|
|
||||||
|
- Guest can submit up to 3 song requests.
|
||||||
|
- Guest can submit optional toast/speech request.
|
||||||
|
- Guest can submit advice/wish/memory message.
|
||||||
|
- Guest-facing text is localized by invitation language.
|
||||||
|
- Admin can view all submitted interaction data.
|
||||||
|
- Admin can update toast request status if implemented.
|
||||||
|
- Admin pages use shadcn/ui only.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Optional guest interactions work end-to-end.
|
||||||
|
- Admin can review them.
|
||||||
|
- No upload functionality was implemented in this milestone.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Milestone 06 — MinIO Photo Uploads
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Read `plans/wedding-bootstrap-plan.md` first. This milestone depends on Milestones 01, 03, and 04.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add one-photo-per-invitation upload support using MinIO through S3-compatible presigned URLs.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implement only:
|
||||||
|
|
||||||
|
- S3/MinIO client.
|
||||||
|
- Presigned upload endpoint.
|
||||||
|
- Upload completion endpoint.
|
||||||
|
- Guest-facing photo upload UI.
|
||||||
|
- One-current-photo-per-invitation behavior.
|
||||||
|
- Replacement until deadline.
|
||||||
|
- Admin signed preview/download URL.
|
||||||
|
- Upload validation.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- Multiple photos per invitation.
|
||||||
|
- Public unauthenticated photo browsing.
|
||||||
|
- Image processing/resizing unless explicitly requested later.
|
||||||
|
- Complex gallery UI.
|
||||||
|
- Another storage provider.
|
||||||
|
|
||||||
|
## Required constraints
|
||||||
|
|
||||||
|
- Use MinIO via S3-compatible AWS SDK APIs.
|
||||||
|
- One image per invitation/group.
|
||||||
|
- Replacing the image is allowed until `RSVP_DEADLINE`.
|
||||||
|
- After the deadline, guest upload is read-only/disabled.
|
||||||
|
- Validate invitation token before presigning upload.
|
||||||
|
- Admin UI must use shadcn/ui components only.
|
||||||
|
- Guest-facing copy must use `invitations.language`.
|
||||||
|
|
||||||
|
## Implementation tasks
|
||||||
|
|
||||||
|
- Add `lib/storage/s3.ts`.
|
||||||
|
- Add upload helper functions in `lib/storage/uploads.ts`.
|
||||||
|
- Add validation schema for presign/complete requests.
|
||||||
|
- Add `app/api/uploads/presign/route.ts`.
|
||||||
|
- Add `app/api/uploads/complete/route.ts`.
|
||||||
|
- Generate object keys like:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
invitations/{invitationId}/{uuid}-{safeFilename}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Upsert `photo_uploads` metadata.
|
||||||
|
- Optionally delete old object when replacing.
|
||||||
|
- Add guest upload component/section.
|
||||||
|
- Add localized upload copy.
|
||||||
|
- Add admin preview/download via short-lived signed GET URL.
|
||||||
|
|
||||||
|
## Validation rules
|
||||||
|
|
||||||
|
- Allowed MIME types:
|
||||||
|
- `image/jpeg`
|
||||||
|
- `image/png`
|
||||||
|
- `image/webp`
|
||||||
|
- Enforce a maximum file size.
|
||||||
|
- Require valid invitation token.
|
||||||
|
- Require deadline not passed for guest upload/replace.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres minio minio-init
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual checks:
|
||||||
|
|
||||||
|
- Guest can upload one valid image.
|
||||||
|
- Admin can preview/download the image.
|
||||||
|
- Guest can replace image before deadline.
|
||||||
|
- Metadata in `photo_uploads` is updated on replace.
|
||||||
|
- Invalid MIME type is rejected.
|
||||||
|
- Oversized file is rejected.
|
||||||
|
- Invalid token cannot presign upload.
|
||||||
|
- After deadline, upload/replace is disabled.
|
||||||
|
- Admin pages use shadcn/ui only.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- MinIO upload flow works end-to-end.
|
||||||
|
- Exactly one current photo is associated with each invitation.
|
||||||
|
- Uploads are secured by invitation token and deadline.
|
||||||
|
- Admin can access uploaded photo previews/downloads.
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
# Wedding Website Bootstrap Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build the dynamic foundation for a wedding website before final visual/content design is complete.
|
||||||
|
|
||||||
|
The website should support:
|
||||||
|
|
||||||
|
- A public wedding homepage with static sections and personalized guest content.
|
||||||
|
- Unique invitation links for each invited group/household.
|
||||||
|
- RSVP and per-person attendance/meal collection.
|
||||||
|
- Optional guest contributions such as song requests, toast requests, messages, and one photo upload.
|
||||||
|
- A private `/admin` portal restricted to the couple through Google login.
|
||||||
|
- A Docker Compose runtime with PostgreSQL and MinIO.
|
||||||
|
|
||||||
|
## Confirmed decisions
|
||||||
|
|
||||||
|
### Application stack
|
||||||
|
|
||||||
|
- Framework: Next.js App Router.
|
||||||
|
- Database: PostgreSQL.
|
||||||
|
- Query layer: Kysely, no ORM.
|
||||||
|
- Authentication: Google OAuth.
|
||||||
|
- Admin access: restricted by email allowlist from `.env`.
|
||||||
|
- Object storage: MinIO using S3-compatible APIs.
|
||||||
|
- Deployment/runtime style: Docker Compose.
|
||||||
|
- Admin UI component system: shadcn/ui only.
|
||||||
|
|
||||||
|
### Admin UI requirement
|
||||||
|
|
||||||
|
All UI components used in the admin area must come from shadcn/ui.
|
||||||
|
|
||||||
|
This applies to every admin page under:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/admin
|
||||||
|
/admin/*
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin area must use shadcn/ui components for:
|
||||||
|
|
||||||
|
- layout primitives where applicable
|
||||||
|
- cards
|
||||||
|
- buttons
|
||||||
|
- forms
|
||||||
|
- inputs
|
||||||
|
- selects
|
||||||
|
- checkboxes
|
||||||
|
- dialogs
|
||||||
|
- dropdown menus
|
||||||
|
- tables
|
||||||
|
- badges
|
||||||
|
- tabs
|
||||||
|
- alerts
|
||||||
|
- toasts/sonner notifications
|
||||||
|
- action menus
|
||||||
|
- empty states where applicable
|
||||||
|
- loading/skeleton states
|
||||||
|
|
||||||
|
Custom admin UI should only be composed from shadcn/ui components and small local wrappers around them. Do not introduce another component library for the admin portal.
|
||||||
|
|
||||||
|
If a needed admin component is not yet installed from shadcn/ui, add it through the shadcn workflow and then reuse it consistently.
|
||||||
|
|
||||||
|
The guest-facing invitation pages may have custom wedding-themed design, but the admin management UI must remain shadcn-based.
|
||||||
|
|
||||||
|
### Invitation model
|
||||||
|
|
||||||
|
- The primary guest-facing unit is an `Invitation`, not an individual guest.
|
||||||
|
- An invitation represents a household, couple, family, individual guest, or group.
|
||||||
|
- Each invitation has one unique private token and one invite URL:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/invite/[token]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Each invitation must have a required language.
|
||||||
|
- The invitation language is the source of truth for which language the guest-facing invitation page uses.
|
||||||
|
- This is a core requirement because many guests may only speak one of the wedding languages.
|
||||||
|
- Admins choose the invitation language when creating or editing an invitation.
|
||||||
|
- Admin pages can remain in the couple's preferred management language initially, but guest-facing copy must respect the invitation language.
|
||||||
|
|
||||||
|
### Invitation members
|
||||||
|
|
||||||
|
- Each invitation contains one or more `InvitationMember` records.
|
||||||
|
- Members are modeled from the start because attendance and food choices can differ per person.
|
||||||
|
- Per-person attendance lives on `invitation_members.attending`.
|
||||||
|
- The response table must not duplicate attendance status.
|
||||||
|
|
||||||
|
### RSVP/response model
|
||||||
|
|
||||||
|
- `invitation_members` owns per-person answers:
|
||||||
|
- attending
|
||||||
|
- meal preference
|
||||||
|
- dietary restrictions
|
||||||
|
- `invitation_responses` owns group-level logistics:
|
||||||
|
- submitted timestamp
|
||||||
|
- travel dates
|
||||||
|
- stay location
|
||||||
|
- transport help
|
||||||
|
- message to the couple
|
||||||
|
- RSVP edits are allowed until a configured deadline.
|
||||||
|
|
||||||
|
### Photo uploads
|
||||||
|
|
||||||
|
- One image per invitation/group.
|
||||||
|
- The image may be replaced until the deadline.
|
||||||
|
- Store files in MinIO.
|
||||||
|
- Store object metadata in PostgreSQL.
|
||||||
|
|
||||||
|
### Optional wedding interactions
|
||||||
|
|
||||||
|
Include these from the bootstrap if feasible:
|
||||||
|
|
||||||
|
- Song requests.
|
||||||
|
- Optional toast/speech request.
|
||||||
|
- Advice, wish, or favorite memory message.
|
||||||
|
|
||||||
|
## Proposed environment variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://wedding:wedding@localhost:5432/wedding
|
||||||
|
|
||||||
|
AUTH_SECRET=change-me
|
||||||
|
AUTH_GOOGLE_ID=change-me
|
||||||
|
AUTH_GOOGLE_SECRET=change-me
|
||||||
|
ADMIN_EMAILS=person-one@example.com,person-two@example.com
|
||||||
|
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
RSVP_DEADLINE=2026-08-01
|
||||||
|
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=wedding-uploads
|
||||||
|
S3_ACCESS_KEY_ID=minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Target folder structure
|
||||||
|
|
||||||
|
```txt
|
||||||
|
app/
|
||||||
|
page.tsx
|
||||||
|
invite/
|
||||||
|
[token]/
|
||||||
|
page.tsx
|
||||||
|
admin/
|
||||||
|
page.tsx
|
||||||
|
guests/
|
||||||
|
page.tsx
|
||||||
|
new/
|
||||||
|
page.tsx
|
||||||
|
[id]/
|
||||||
|
page.tsx
|
||||||
|
api/
|
||||||
|
auth/
|
||||||
|
[...nextauth]/
|
||||||
|
route.ts
|
||||||
|
uploads/
|
||||||
|
presign/
|
||||||
|
route.ts
|
||||||
|
complete/
|
||||||
|
route.ts
|
||||||
|
|
||||||
|
lib/
|
||||||
|
auth.ts
|
||||||
|
config.ts
|
||||||
|
db/
|
||||||
|
client.ts
|
||||||
|
types.ts
|
||||||
|
queries/
|
||||||
|
invitations.ts
|
||||||
|
members.ts
|
||||||
|
responses.ts
|
||||||
|
uploads.ts
|
||||||
|
song-requests.ts
|
||||||
|
toast-requests.ts
|
||||||
|
guest-messages.ts
|
||||||
|
storage/
|
||||||
|
s3.ts
|
||||||
|
uploads.ts
|
||||||
|
i18n/
|
||||||
|
config.ts
|
||||||
|
dictionaries/
|
||||||
|
en.ts
|
||||||
|
es.ts
|
||||||
|
de.ts
|
||||||
|
validation/
|
||||||
|
invitation.ts
|
||||||
|
response.ts
|
||||||
|
upload.ts
|
||||||
|
interactions.ts
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
migrate-sql.mjs
|
||||||
|
|
||||||
|
migrations/
|
||||||
|
0001_initial_schema.sql
|
||||||
|
|
||||||
|
docker-compose.yml
|
||||||
|
plans/
|
||||||
|
wedding-bootstrap-plan.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database schema plan
|
||||||
|
|
||||||
|
### `invitations`
|
||||||
|
|
||||||
|
Represents one invite/group/household.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id uuid primary key
|
||||||
|
token text unique not null
|
||||||
|
display_name text not null
|
||||||
|
language text not null
|
||||||
|
contact_email text
|
||||||
|
contact_phone text
|
||||||
|
max_party_size integer not null default 1
|
||||||
|
allow_member_edits boolean not null default false
|
||||||
|
notes text
|
||||||
|
created_at timestamptz not null
|
||||||
|
updated_at timestamptz not null
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `token` must be unguessable.
|
||||||
|
- `display_name` is what the guest sees, e.g. `Ana & Luis` or `The Garcia Family`.
|
||||||
|
- `language` controls guest-facing page copy and form labels for this invitation.
|
||||||
|
- Recommended initial language values should be short locale codes such as `en`, `es`, `de`, etc.
|
||||||
|
- `allow_member_edits` allows flexible +1 or family edits later.
|
||||||
|
|
||||||
|
### `invitation_members`
|
||||||
|
|
||||||
|
Represents each individual person under an invitation.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id uuid primary key
|
||||||
|
invitation_id uuid not null references invitations(id) on delete cascade
|
||||||
|
name text not null
|
||||||
|
is_primary_contact boolean not null default false
|
||||||
|
attending boolean
|
||||||
|
meal_preference text
|
||||||
|
dietary_restrictions text
|
||||||
|
notes text
|
||||||
|
created_at timestamptz not null
|
||||||
|
updated_at timestamptz not null
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `attending = null` means pending/unanswered.
|
||||||
|
- Attendance status is derived from this table, not from `invitation_responses`.
|
||||||
|
- Meal preference examples: `meat`, `fish`, `vegetarian`, `vegan`, `kids`, `other`.
|
||||||
|
|
||||||
|
### `invitation_responses`
|
||||||
|
|
||||||
|
Stores group-level RSVP metadata and logistics.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id uuid primary key
|
||||||
|
invitation_id uuid unique not null references invitations(id) on delete cascade
|
||||||
|
submitted_at timestamptz
|
||||||
|
arrival_date date
|
||||||
|
departure_date date
|
||||||
|
stay_location text
|
||||||
|
coming_from text
|
||||||
|
needs_transport_help boolean
|
||||||
|
message_to_couple text
|
||||||
|
created_at timestamptz not null
|
||||||
|
updated_at timestamptz not null
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- No attendance status here.
|
||||||
|
- No attendee count here.
|
||||||
|
- Admin dashboard derives totals from `invitation_members`.
|
||||||
|
|
||||||
|
### `photo_uploads`
|
||||||
|
|
||||||
|
Stores one uploaded activity/surprise image per invitation.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id uuid primary key
|
||||||
|
invitation_id uuid unique not null references invitations(id) on delete cascade
|
||||||
|
bucket text not null
|
||||||
|
object_key text not null
|
||||||
|
original_filename text
|
||||||
|
content_type text
|
||||||
|
size_bytes integer
|
||||||
|
created_at timestamptz not null
|
||||||
|
updated_at timestamptz not null
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Unique `invitation_id` enforces one photo per invitation.
|
||||||
|
- Replacing a photo updates this record and may delete/overwrite the old object.
|
||||||
|
|
||||||
|
### `song_requests`
|
||||||
|
|
||||||
|
Stores optional party song requests.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id uuid primary key
|
||||||
|
invitation_id uuid not null references invitations(id) on delete cascade
|
||||||
|
title text not null
|
||||||
|
artist text
|
||||||
|
note text
|
||||||
|
created_at timestamptz not null
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Recommended limit: up to 3 song requests per invitation.
|
||||||
|
|
||||||
|
### `toast_requests`
|
||||||
|
|
||||||
|
Stores optional requests to say a few words.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id uuid primary key
|
||||||
|
invitation_id uuid not null references invitations(id) on delete cascade
|
||||||
|
member_id uuid references invitation_members(id) on delete set null
|
||||||
|
speaker_name text not null
|
||||||
|
estimated_minutes integer
|
||||||
|
note text
|
||||||
|
status text not null default 'pending'
|
||||||
|
created_at timestamptz not null
|
||||||
|
updated_at timestamptz not null
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended statuses:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
pending
|
||||||
|
accepted
|
||||||
|
declined
|
||||||
|
scheduled
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Toast requests are voluntary.
|
||||||
|
- Admin approval is required before scheduling.
|
||||||
|
|
||||||
|
### `guest_messages`
|
||||||
|
|
||||||
|
Stores optional advice, wishes, or memories.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id uuid primary key
|
||||||
|
invitation_id uuid not null references invitations(id) on delete cascade
|
||||||
|
type text not null
|
||||||
|
message text not null
|
||||||
|
created_at timestamptz not null
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended message types:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
advice
|
||||||
|
memory
|
||||||
|
wish
|
||||||
|
```
|
||||||
|
|
||||||
|
### `admin_audit_events` optional
|
||||||
|
|
||||||
|
Useful but not required for the first pass.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id uuid primary key
|
||||||
|
admin_email text not null
|
||||||
|
action text not null
|
||||||
|
target_type text
|
||||||
|
target_id uuid
|
||||||
|
metadata jsonb
|
||||||
|
created_at timestamptz not null
|
||||||
|
```
|
||||||
|
|
||||||
|
## Derived RSVP status logic
|
||||||
|
|
||||||
|
Admin-facing status should be computed instead of stored redundantly.
|
||||||
|
|
||||||
|
Suggested derived statuses:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Pending
|
||||||
|
invitation_response.submitted_at is null
|
||||||
|
|
||||||
|
Partially answered
|
||||||
|
submitted_at exists and at least one member has attending = null
|
||||||
|
|
||||||
|
Attending
|
||||||
|
at least one member has attending = true and no members are pending
|
||||||
|
|
||||||
|
Declined
|
||||||
|
all members have attending = false and no members are pending
|
||||||
|
|
||||||
|
Partial
|
||||||
|
at least one member attending = true and at least one member attending = false
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard metrics can derive:
|
||||||
|
|
||||||
|
- total invitations
|
||||||
|
- total invited members
|
||||||
|
- attending members
|
||||||
|
- declined members
|
||||||
|
- pending members
|
||||||
|
- invitations with submitted responses
|
||||||
|
- invitations with uploaded photos
|
||||||
|
- song request count
|
||||||
|
- pending toast requests
|
||||||
|
|
||||||
|
## Guest-facing invite flow
|
||||||
|
|
||||||
|
Route:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/invite/[token]
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. Resolve invitation by token.
|
||||||
|
2. If token is invalid, show a friendly invalid-invitation page.
|
||||||
|
3. If valid, render personalized wedding page shell.
|
||||||
|
4. Load guest-facing copy based on `invitations.language`.
|
||||||
|
5. Show static wedding information:
|
||||||
|
- hero
|
||||||
|
- date
|
||||||
|
- countdown
|
||||||
|
- location
|
||||||
|
- accommodation information
|
||||||
|
- short couple story placeholder
|
||||||
|
6. Show RSVP section with all invitation members.
|
||||||
|
7. For each member:
|
||||||
|
- attending yes/no
|
||||||
|
- meal preference if attending
|
||||||
|
- dietary restrictions if attending
|
||||||
|
8. Show group logistics:
|
||||||
|
- arrival date
|
||||||
|
- departure date
|
||||||
|
- stay location
|
||||||
|
- coming from
|
||||||
|
- transport help
|
||||||
|
- message to the couple
|
||||||
|
9. Show optional interaction sections:
|
||||||
|
- song requests
|
||||||
|
- toast/speech request
|
||||||
|
- advice/wish/memory
|
||||||
|
- one photo upload
|
||||||
|
10. Before deadline, allow edits.
|
||||||
|
11. After deadline, show submitted answers read-only and explain that changes require contacting the couple.
|
||||||
|
|
||||||
|
Guest-facing localized content should include at least:
|
||||||
|
|
||||||
|
- page headings
|
||||||
|
- navigation/section labels
|
||||||
|
- RSVP form labels
|
||||||
|
- validation messages
|
||||||
|
- success/error messages
|
||||||
|
- deadline/read-only messages
|
||||||
|
- upload instructions
|
||||||
|
- optional interaction prompts
|
||||||
|
|
||||||
|
Wedding factual content, such as venue names and addresses, can remain shared, but surrounding explanatory copy should be localized.
|
||||||
|
|
||||||
|
## Admin portal plan
|
||||||
|
|
||||||
|
Admin routes:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/admin
|
||||||
|
/admin/guests
|
||||||
|
/admin/guests/new
|
||||||
|
/admin/guests/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Although the domain model is `invitations`, the UI can still use guest-friendly labels like `Guests`.
|
||||||
|
|
||||||
|
### `/admin`
|
||||||
|
|
||||||
|
Dashboard cards:
|
||||||
|
|
||||||
|
- Total invitations.
|
||||||
|
- Total invited people.
|
||||||
|
- RSVP pending.
|
||||||
|
- Attending people.
|
||||||
|
- Declined people.
|
||||||
|
- Uploaded photos.
|
||||||
|
- Song requests.
|
||||||
|
- Pending toast requests.
|
||||||
|
|
||||||
|
### `/admin/guests`
|
||||||
|
|
||||||
|
Invitation list table:
|
||||||
|
|
||||||
|
- display name
|
||||||
|
- language
|
||||||
|
- contact email
|
||||||
|
- contact phone
|
||||||
|
- members count
|
||||||
|
- derived RSVP status
|
||||||
|
- attending count
|
||||||
|
- invite link
|
||||||
|
- last updated
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
- copy invite link
|
||||||
|
- view details
|
||||||
|
- edit
|
||||||
|
|
||||||
|
### `/admin/guests/new`
|
||||||
|
|
||||||
|
Create invitation form:
|
||||||
|
|
||||||
|
- display name
|
||||||
|
- language
|
||||||
|
- contact email
|
||||||
|
- contact phone
|
||||||
|
- max party size
|
||||||
|
- allow member edits
|
||||||
|
- notes
|
||||||
|
- initial members
|
||||||
|
|
||||||
|
On submit:
|
||||||
|
|
||||||
|
- generate token
|
||||||
|
- create invitation
|
||||||
|
- create initial members
|
||||||
|
- show generated invite URL
|
||||||
|
|
||||||
|
The language field is required.
|
||||||
|
|
||||||
|
### `/admin/guests/[id]`
|
||||||
|
|
||||||
|
Detail page:
|
||||||
|
|
||||||
|
- invitation metadata
|
||||||
|
- invite token/link
|
||||||
|
- member list and per-person RSVP answers
|
||||||
|
- group logistics
|
||||||
|
- uploaded photo preview/link
|
||||||
|
- song requests
|
||||||
|
- toast requests with admin status controls
|
||||||
|
- advice/wish/memory messages
|
||||||
|
- admin notes
|
||||||
|
|
||||||
|
Admin actions:
|
||||||
|
|
||||||
|
- edit invitation
|
||||||
|
- add/edit/remove members if needed
|
||||||
|
- regenerate invite token
|
||||||
|
- manually adjust RSVP/member data
|
||||||
|
- accept/decline/schedule toast request
|
||||||
|
|
||||||
|
## Authentication plan
|
||||||
|
|
||||||
|
Use Google OAuth with an email allowlist.
|
||||||
|
|
||||||
|
Implementation targets:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
auth.ts
|
||||||
|
app/api/auth/[...nextauth]/route.ts
|
||||||
|
middleware.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Only emails listed in `ADMIN_EMAILS` may access `/admin` and `/admin/*`.
|
||||||
|
- Non-admin Google users are rejected or shown a forbidden page.
|
||||||
|
- Guest invite pages do not require login.
|
||||||
|
- Admin emails remain in `.env` for the bootstrap.
|
||||||
|
|
||||||
|
Before implementation, verify the exact current Next.js/Auth.js setup for the installed framework version.
|
||||||
|
|
||||||
|
## MinIO upload plan
|
||||||
|
|
||||||
|
Use AWS SDK S3-compatible client.
|
||||||
|
|
||||||
|
Upload flow:
|
||||||
|
|
||||||
|
1. Guest selects one image.
|
||||||
|
2. Client asks the app for a presigned upload URL.
|
||||||
|
3. App validates invitation token and deadline.
|
||||||
|
4. App creates an object key:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
invitations/{invitationId}/{uuid}-{safeFilename}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Browser uploads directly to MinIO using presigned PUT.
|
||||||
|
6. Client calls complete endpoint.
|
||||||
|
7. App upserts `photo_uploads` metadata for the invitation.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- Allow only image MIME types:
|
||||||
|
- `image/jpeg`
|
||||||
|
- `image/png`
|
||||||
|
- `image/webp`
|
||||||
|
- Enforce a max size.
|
||||||
|
- Enforce one current photo per invitation.
|
||||||
|
|
||||||
|
Admin viewing:
|
||||||
|
|
||||||
|
- Generate short-lived signed GET URLs for previews/downloads.
|
||||||
|
|
||||||
|
## Docker Compose plan
|
||||||
|
|
||||||
|
Services:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
app
|
||||||
|
postgres
|
||||||
|
minio
|
||||||
|
minio-init
|
||||||
|
```
|
||||||
|
|
||||||
|
Postgres:
|
||||||
|
|
||||||
|
- expose `5432`
|
||||||
|
- database: `wedding`
|
||||||
|
- user: `wedding`
|
||||||
|
- password: `wedding`
|
||||||
|
|
||||||
|
MinIO:
|
||||||
|
|
||||||
|
- API: `9000`
|
||||||
|
- console: `9001`
|
||||||
|
- bucket: `wedding-uploads`
|
||||||
|
|
||||||
|
`minio-init`:
|
||||||
|
|
||||||
|
- wait for MinIO
|
||||||
|
- create bucket if it does not exist
|
||||||
|
|
||||||
|
## Implementation sequence
|
||||||
|
|
||||||
|
### Step 1: Review framework-specific docs
|
||||||
|
|
||||||
|
Because this project uses Next.js 16, review the locally installed Next documentation before coding framework-specific pieces.
|
||||||
|
|
||||||
|
Focus areas:
|
||||||
|
|
||||||
|
- App Router route handlers.
|
||||||
|
- Server actions/forms.
|
||||||
|
- Middleware behavior.
|
||||||
|
- Authentication integration compatibility.
|
||||||
|
|
||||||
|
### Step 2: Add dependencies
|
||||||
|
|
||||||
|
Install likely packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install kysely pg zod next-auth @aws-sdk/client-s3 @aws-sdk/s3-request-presigner nanoid
|
||||||
|
npm install -D @types/pg kysely-codegen
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust if current package versions require different auth package names or setup.
|
||||||
|
|
||||||
|
### Step 3: Add environment handling
|
||||||
|
|
||||||
|
- Create `.env.example`.
|
||||||
|
- Add typed config helper in `lib/config.ts`.
|
||||||
|
- Validate required env vars at startup/server usage.
|
||||||
|
|
||||||
|
### Step 4: Add Docker Compose
|
||||||
|
|
||||||
|
- Add `docker-compose.yml`.
|
||||||
|
- Define Postgres service.
|
||||||
|
- Define MinIO service.
|
||||||
|
- Define MinIO bucket initialization.
|
||||||
|
- Optionally define app service.
|
||||||
|
|
||||||
|
### Step 5: Add Kysely database client
|
||||||
|
|
||||||
|
- Create `lib/db/client.ts`.
|
||||||
|
- Create initial manual DB types in `lib/db/types.ts`.
|
||||||
|
- Add SQL migration runner in `scripts/migrate-sql.mjs`.
|
||||||
|
- Add package script:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"db:migrate": "node scripts/migrate-sql.mjs"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Create initial SQL migration
|
||||||
|
|
||||||
|
Create `migrations/0001_initial_schema.sql` for:
|
||||||
|
|
||||||
|
- `invitations`
|
||||||
|
- `invitation_members`
|
||||||
|
- `invitation_responses`
|
||||||
|
- `photo_uploads`
|
||||||
|
- `song_requests`
|
||||||
|
- `toast_requests`
|
||||||
|
- `guest_messages`
|
||||||
|
- optional `admin_audit_events`
|
||||||
|
|
||||||
|
### Step 7: Add query modules
|
||||||
|
|
||||||
|
Create query helpers for:
|
||||||
|
|
||||||
|
- invitations
|
||||||
|
- invitation members
|
||||||
|
- invitation responses
|
||||||
|
- uploads
|
||||||
|
- song requests
|
||||||
|
- toast requests
|
||||||
|
- guest messages
|
||||||
|
|
||||||
|
Keep SQL explicit and typed through Kysely.
|
||||||
|
|
||||||
|
### Step 8: Add validation schemas
|
||||||
|
|
||||||
|
Use Zod for:
|
||||||
|
|
||||||
|
- creating/updating invitations
|
||||||
|
- creating/updating members
|
||||||
|
- submitting RSVP/member attendance
|
||||||
|
- submitting logistics
|
||||||
|
- song requests
|
||||||
|
- toast requests
|
||||||
|
- guest messages
|
||||||
|
- upload presign/complete requests
|
||||||
|
|
||||||
|
Invitation validation must require a supported language code.
|
||||||
|
|
||||||
|
### Step 9: Implement Google admin auth
|
||||||
|
|
||||||
|
- Configure Google provider.
|
||||||
|
- Restrict sign-in to `ADMIN_EMAILS`.
|
||||||
|
- Protect `/admin` routes.
|
||||||
|
- Add sign-in/sign-out UI as needed.
|
||||||
|
|
||||||
|
### Step 10: Build admin MVP
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- `/admin` dashboard.
|
||||||
|
- `/admin/guests` invitation list.
|
||||||
|
- `/admin/guests/new` create form.
|
||||||
|
- `/admin/guests/[id]` detail page.
|
||||||
|
|
||||||
|
Minimum admin operations:
|
||||||
|
|
||||||
|
- create invitation
|
||||||
|
- add members
|
||||||
|
- copy invite link
|
||||||
|
- view submitted RSVP data
|
||||||
|
- view optional guest interactions
|
||||||
|
|
||||||
|
### Step 11: Build guest invite MVP
|
||||||
|
|
||||||
|
Implement `/invite/[token]` with:
|
||||||
|
|
||||||
|
- invalid token handling
|
||||||
|
- personalized greeting
|
||||||
|
- language-aware guest-facing copy based on `invitations.language`
|
||||||
|
- wedding info shell
|
||||||
|
- countdown
|
||||||
|
- member attendance form
|
||||||
|
- per-member meal/dietary fields
|
||||||
|
- group logistics form
|
||||||
|
- submission/edit behavior until deadline
|
||||||
|
|
||||||
|
### Step 12: Add optional guest interactions
|
||||||
|
|
||||||
|
Add form sections for:
|
||||||
|
|
||||||
|
- up to 3 song requests
|
||||||
|
- optional toast/speech request
|
||||||
|
- advice/wish/memory message
|
||||||
|
|
||||||
|
Admin should be able to view all of them.
|
||||||
|
|
||||||
|
### Step 13: Add MinIO photo upload
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- S3 client.
|
||||||
|
- presigned PUT endpoint.
|
||||||
|
- upload complete endpoint.
|
||||||
|
- one-photo-per-invitation metadata upsert.
|
||||||
|
- admin signed preview/download URL.
|
||||||
|
|
||||||
|
### Step 14: Add basic design shell
|
||||||
|
|
||||||
|
Replace default starter UI with a simple wedding-appropriate layout:
|
||||||
|
|
||||||
|
- elegant typography
|
||||||
|
- hero section
|
||||||
|
- countdown
|
||||||
|
- location/accommodation placeholders
|
||||||
|
- story placeholder
|
||||||
|
- clear guest forms
|
||||||
|
- responsive mobile-first layout
|
||||||
|
|
||||||
|
Final visual design can remain future work.
|
||||||
|
|
||||||
|
### Step 14.5: Add localization foundation
|
||||||
|
|
||||||
|
Add a simple localization layer for guest-facing invitation pages.
|
||||||
|
|
||||||
|
Recommended bootstrap approach:
|
||||||
|
|
||||||
|
- Keep supported language codes in config.
|
||||||
|
- Store the selected language on each invitation.
|
||||||
|
- Resolve guest-facing copy from `invitations.language` on `/invite/[token]`.
|
||||||
|
- Create translation dictionaries for guest-facing copy.
|
||||||
|
- Fallback to a default language only if necessary, but admin should always select a supported language.
|
||||||
|
|
||||||
|
Suggested structure:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
lib/
|
||||||
|
i18n/
|
||||||
|
config.ts
|
||||||
|
dictionaries/
|
||||||
|
en.ts
|
||||||
|
es.ts
|
||||||
|
de.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not rely only on browser language detection for invited guests. The invitation language is the source of truth.
|
||||||
|
|
||||||
|
### Step 15: Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual happy path:
|
||||||
|
|
||||||
|
1. Start Docker Compose.
|
||||||
|
2. Run migrations.
|
||||||
|
3. Log in as admin with allowed Google account.
|
||||||
|
4. Create an invitation with multiple members.
|
||||||
|
5. Set the invitation language.
|
||||||
|
6. Copy invite link.
|
||||||
|
7. Open invite link.
|
||||||
|
8. Confirm the page renders in the invitation's configured language.
|
||||||
|
9. Submit attendance with different meal preferences per member.
|
||||||
|
10. Add travel details.
|
||||||
|
11. Add song request, toast request, and message.
|
||||||
|
12. Upload one photo.
|
||||||
|
13. Return to admin and confirm all data is visible.
|
||||||
|
14. Test invalid invite token.
|
||||||
|
15. Test non-admin access to `/admin`.
|
||||||
|
16. Test deadline read-only behavior.
|
||||||
|
|
||||||
|
## Open implementation questions
|
||||||
|
|
||||||
|
These can be decided during implementation:
|
||||||
|
|
||||||
|
1. Should guests be able to add/edit member names when `allow_member_edits = true`, or only fill unnamed placeholders?
|
||||||
|
2. What should the exact RSVP deadline be?
|
||||||
|
3. What max upload size should be allowed?
|
||||||
|
4. Should admin be able to export guest/RSVP data as CSV in the first version?
|
||||||
|
5. Which exact languages should be supported in the first version?
|
||||||
|
|
||||||
|
## Recommended first milestone
|
||||||
|
|
||||||
|
The first useful milestone should be:
|
||||||
|
|
||||||
|
- Docker Compose with Postgres and MinIO.
|
||||||
|
- Initial pure SQL migration executed by `scripts/migrate-sql.mjs`.
|
||||||
|
- Google admin login restricted by `.env` emails.
|
||||||
|
- Admin can create invitation with members.
|
||||||
|
- Admin can select a language per invitation.
|
||||||
|
- Guest can open `/invite/[token]` and submit per-member RSVP answers.
|
||||||
|
- Guest invite page renders in the invitation's configured language.
|
||||||
|
- Admin can see submitted answers.
|
||||||
|
|
||||||
|
After that, add photo uploads and the fun interaction sections.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { auth as proxy } from "@/auth"
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/admin/:path*"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Client } from "pg";
|
||||||
|
|
||||||
|
function loadEnvFileIfNeeded() {
|
||||||
|
if (process.env.DATABASE_URL) return;
|
||||||
|
|
||||||
|
const envPath = path.join(process.cwd(), ".env");
|
||||||
|
if (!fs.existsSync(envPath)) return;
|
||||||
|
|
||||||
|
const text = fs.readFileSync(envPath, "utf8");
|
||||||
|
for (const rawLine of text.split("\n")) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
const idx = line.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
|
||||||
|
const key = line.slice(0, idx).trim();
|
||||||
|
const value = line.slice(idx + 1).trim();
|
||||||
|
if (!process.env[key]) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationsDir = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"migrations",
|
||||||
|
);
|
||||||
|
|
||||||
|
function listSqlFiles(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((file) => file.endsWith(".sql"))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadEnvFileIfNeeded();
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("DATABASE_URL is not set. Add it to .env or your environment.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = listSqlFiles(migrationsDir);
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(migrationsDir, file);
|
||||||
|
const sql = fs.readFileSync(fullPath, "utf8");
|
||||||
|
console.log(`Applying ${fullPath}`);
|
||||||
|
await client.query(sql);
|
||||||
|
}
|
||||||
|
console.log("Migrations applied successfully");
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Migration failed.");
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user