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*
|
||||
!.env.example
|
||||
|
||||
# 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>
|
||||
)
|
||||
}
|
||||
+69
-11
@@ -1,26 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import {
|
||||
getInvitationInteractions,
|
||||
replaceInvitationInteractions,
|
||||
} from "@/lib/db/queries/interactions"
|
||||
import { getInvitationByToken, submitInvitationResponse } from "@/lib/db/queries/invitations"
|
||||
import { getPhotoUploadByInvitationId } from "@/lib/db/queries/uploads"
|
||||
import { getGuestDictionary } from "@/lib/i18n/get-dictionary"
|
||||
import { getRsvpDeadline, isRsvpClosed } from "@/lib/rsvp-deadline"
|
||||
import { PhotoUpload } from "@/components/invite/photo-upload"
|
||||
import {
|
||||
interactionSubmissionSchema,
|
||||
} from "@/lib/validation/interactions"
|
||||
import {
|
||||
mealPreferenceSchema,
|
||||
submitInvitationResponseSchema,
|
||||
type MealPreference,
|
||||
} from "@/lib/validation/response"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const mealOptions: MealPreference[] = ["meat", "fish", "vegetarian", "vegan", "kids", "other"]
|
||||
|
||||
function formatDateForInput(value: Date | string | null | undefined) {
|
||||
if (!value) return ""
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function getOptionalString(formData: FormData, key: string) {
|
||||
return formData.get(key)?.toString() ?? ""
|
||||
}
|
||||
|
||||
function getTransportHelp(value: FormDataEntryValue | null) {
|
||||
if (value === "yes") return true
|
||||
if (value === "no") return false
|
||||
return null
|
||||
}
|
||||
|
||||
function getToastRequestFromFormData(formData: FormData) {
|
||||
if (!formData.has("wantsToast")) return null
|
||||
|
||||
const speakerName = getOptionalString(formData, "toastSpeakerName")
|
||||
if (!speakerName) return null
|
||||
|
||||
const memberId = getOptionalString(formData, "toastMemberId")
|
||||
|
||||
return {
|
||||
memberId: memberId || null,
|
||||
speakerName,
|
||||
estimatedMinutes: getOptionalString(formData, "toastEstimatedMinutes"),
|
||||
}
|
||||
}
|
||||
|
||||
function deadlineMessage(template: string) {
|
||||
const deadline = getRsvpDeadline()
|
||||
return template.replace("{date}", deadline ?? "the deadline")
|
||||
}
|
||||
|
||||
async function submitRsvpAction(token: string, formData: FormData) {
|
||||
"use server"
|
||||
|
||||
if (isRsvpClosed()) {
|
||||
redirect(`/invite/${token}?error=closed`)
|
||||
}
|
||||
|
||||
const invitation = await getInvitationByToken(token)
|
||||
|
||||
if (!invitation) {
|
||||
redirect(`/invite/${token}?error=invalid`)
|
||||
}
|
||||
|
||||
const members = invitation.members.map((member) => {
|
||||
const attendingValue = formData.get(`attending-${member.id}`)
|
||||
|
||||
if (attendingValue !== "yes" && attendingValue !== "no") {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
const attending = attendingValue === "yes"
|
||||
const rawMealPreference = formData.get(`meal-${member.id}`)?.toString() ?? ""
|
||||
const mealPreference = mealPreferenceSchema.safeParse(rawMealPreference)
|
||||
|
||||
return {
|
||||
memberId: member.id,
|
||||
attending,
|
||||
mealPreference: attending && mealPreference.success ? mealPreference.data : null,
|
||||
dietaryRestrictions: getOptionalString(formData, `dietary-${member.id}`),
|
||||
}
|
||||
})
|
||||
|
||||
const parsed = submitInvitationResponseSchema.safeParse({
|
||||
token,
|
||||
members,
|
||||
arrivalDate: formData.get("arrivalDate"),
|
||||
departureDate: formData.get("departureDate"),
|
||||
stayLocation: formData.get("stayLocation"),
|
||||
needsTransportHelp: getTransportHelp(formData.get("needsTransportHelp")),
|
||||
messageToCouple: formData.get("messageToCouple"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
const interactions = interactionSubmissionSchema.safeParse({
|
||||
invitationId: invitation.invitation.id,
|
||||
toastRequest: getToastRequestFromFormData(formData),
|
||||
})
|
||||
|
||||
if (!interactions.success) {
|
||||
redirect(`/invite/${token}?error=generic`)
|
||||
}
|
||||
|
||||
await submitInvitationResponse(parsed.data)
|
||||
await replaceInvitationInteractions(interactions.data)
|
||||
redirect(`/invite/${token}?submitted=1`)
|
||||
}
|
||||
|
||||
export default async function InvitePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ token: string }>
|
||||
searchParams: Promise<{ submitted?: string; error?: string }>
|
||||
}) {
|
||||
const [{ token }, { submitted, error }] = await Promise.all([params, searchParams])
|
||||
const detail = await getInvitationByToken(token)
|
||||
|
||||
if (!detail) {
|
||||
const dictionary = getGuestDictionary("en")
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-stone-50 px-6 py-12">
|
||||
<section className="max-w-lg rounded-3xl border bg-white p-8 text-center shadow-sm">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-stone-500">
|
||||
{dictionary.hero.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold text-stone-950">
|
||||
{dictionary.invalid.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-stone-600">{dictionary.invalid.description}</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const dictionary = getGuestDictionary(detail.invitation.language)
|
||||
const [interactions, photoUpload] = await Promise.all([
|
||||
getInvitationInteractions(detail.invitation.id),
|
||||
getPhotoUploadByInvitationId(detail.invitation.id),
|
||||
])
|
||||
const closed = isRsvpClosed()
|
||||
const action = submitRsvpAction.bind(null, token)
|
||||
const greeting = dictionary.hero.greeting.replace("{name}", detail.invitation.display_name)
|
||||
const toastRequest = interactions.toastRequests[0]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 text-stone-950">
|
||||
<section className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-12">
|
||||
<div className="rounded-[2rem] bg-white p-8 shadow-sm ring-1 ring-stone-200 md:p-12">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.3em] text-stone-500">
|
||||
{dictionary.hero.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-5 max-w-3xl text-4xl font-semibold tracking-tight md:text-6xl">
|
||||
{dictionary.hero.title}
|
||||
</h1>
|
||||
<p className="mt-6 text-xl font-medium text-stone-800">{greeting}</p>
|
||||
<p className="mt-3 max-w-2xl text-stone-600">{dictionary.hero.description}</p>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-emerald-950">
|
||||
{dictionary.form.submitted}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-red-950">
|
||||
{error === "closed" ? dictionary.errors.closed : dictionary.errors.generic}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.dateLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.dateValue}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.locationLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.locationValue}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-stone-500">{dictionary.wedding.accommodationLabel}</p>
|
||||
<p className="mt-2 font-medium">{dictionary.wedding.accommodationValue}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form action={action} className="space-y-8">
|
||||
<section className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="flex flex-col gap-2 border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.rsvp}</h2>
|
||||
<p className="text-stone-600">
|
||||
{closed
|
||||
? dictionary.deadline.closed
|
||||
: deadlineMessage(dictionary.deadline.open)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{detail.members.map((member) => (
|
||||
<fieldset className="space-y-5 py-6" disabled={closed} key={member.id}>
|
||||
<legend className="text-lg font-semibold">{member.name}</legend>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{dictionary.form.attending}</p>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={`attending-${member.id}`}
|
||||
value="yes"
|
||||
defaultChecked={member.attending === true}
|
||||
required
|
||||
/>
|
||||
{dictionary.form.yes}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={`attending-${member.id}`}
|
||||
value="no"
|
||||
defaultChecked={member.attending === false}
|
||||
required
|
||||
/>
|
||||
{dictionary.form.no}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.mealPreference}</span>
|
||||
<select
|
||||
name={`meal-${member.id}`}
|
||||
defaultValue={member.meal_preference ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
>
|
||||
<option value="">{dictionary.form.mealPlaceholder}</option>
|
||||
{mealOptions.map((meal) => (
|
||||
<option key={meal} value={meal}>
|
||||
{dictionary.meals[meal]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.dietaryRestrictions}</span>
|
||||
<input
|
||||
name={`dietary-${member.id}`}
|
||||
defaultValue={member.dietary_restrictions ?? ""}
|
||||
placeholder={dictionary.form.noRestrictions}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<fieldset disabled={closed} className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.travel}</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.arrivalDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
name="arrivalDate"
|
||||
defaultValue={formatDateForInput(detail.response?.arrival_date)}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.departureDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
name="departureDate"
|
||||
defaultValue={formatDateForInput(detail.response?.departure_date)}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.stayLocation}</span>
|
||||
<input
|
||||
name="stayLocation"
|
||||
defaultValue={detail.response?.stay_location ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{dictionary.form.needsTransportHelp}</p>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="needsTransportHelp"
|
||||
value="yes"
|
||||
defaultChecked={detail.response?.needs_transport_help === true}
|
||||
/>
|
||||
{dictionary.form.yes}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="needsTransportHelp"
|
||||
value="no"
|
||||
defaultChecked={detail.response?.needs_transport_help === false}
|
||||
/>
|
||||
{dictionary.form.no}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-sm font-medium">{dictionary.form.messageToCouple}</span>
|
||||
<textarea
|
||||
name="messageToCouple"
|
||||
defaultValue={detail.response?.message_to_couple ?? ""}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset disabled={closed} className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{dictionary.sections.interactions}</h2>
|
||||
<p className="mt-2 text-stone-600">{dictionary.interactions.intro}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-10">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{dictionary.interactions.toastTitle}</h3>
|
||||
<p className="text-sm text-stone-600">
|
||||
{dictionary.interactions.toastDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="wantsToast" defaultChecked={Boolean(toastRequest)} />
|
||||
{dictionary.interactions.wantsToast}
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.interactions.speakerName}</span>
|
||||
<input
|
||||
name="toastSpeakerName"
|
||||
defaultValue={toastRequest?.speaker_name ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">{dictionary.interactions.estimatedMinutes}</span>
|
||||
<input
|
||||
name="toastEstimatedMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={toastRequest?.estimated_minutes ?? ""}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<select name="toastMemberId" defaultValue={toastRequest?.member_id ?? ""} className="hidden">
|
||||
<option value="" />
|
||||
{detail.members.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<PhotoUpload
|
||||
token={token}
|
||||
labels={dictionary.upload}
|
||||
disabled={closed}
|
||||
existingFilename={photoUpload?.original_filename ?? null}
|
||||
/>
|
||||
|
||||
{!closed ? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-stone-950 px-6 py-3 font-medium text-white transition hover:bg-stone-800"
|
||||
>
|
||||
{dictionary.form.submit}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth, signIn } from "@/auth"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { isAdminEmail } from "@/lib/admin-auth"
|
||||
|
||||
async function signInWithGoogle() {
|
||||
"use server"
|
||||
|
||||
await signIn("google", { redirectTo: "/admin" })
|
||||
}
|
||||
|
||||
export default async function SignInPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (isAdminEmail(session?.user?.email)) {
|
||||
redirect("/admin")
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted px-6 py-12">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Wedding admin</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in with an authorized Google account to manage invitations.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert>
|
||||
<AlertTitle>Private area</AlertTitle>
|
||||
<AlertDescription>
|
||||
Access is restricted to the admin emails configured for this wedding.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<form action={signInWithGoogle}>
|
||||
<Button className="w-full" type="submit">
|
||||
Continue with Google
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
};
|
||||
|
||||
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",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"@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-auth": "^5.0.0-beta.31",
|
||||
"pg": "^8.16.3",
|
||||
"radix-ui": "^1.5.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"kysely-codegen": "^0.19.0",
|
||||
"tailwindcss": "^4",
|
||||
"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