refactor: migrate dashboard pages from (auth) to (dashboard) route group and update dashboard view logic.

This commit is contained in:
2026-01-19 02:11:50 +02:00
parent 3f364593da
commit e8dc972972
20 changed files with 759 additions and 1575 deletions

View File

@@ -42,6 +42,8 @@ const nextConfig: NextConfig = {
root: path.join(__dirname), // Use current directory (app/) as root
},
serverExternalPackages: ['pg', '@prisma/client', 'prisma', '@prisma/adapter-pg'],
// Security headers for additional protection
async headers() {
return [

View File

@@ -1,56 +1,71 @@
import { PrismaClient, RsvpStatus } from '@prisma/client';
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
import dotenv from "dotenv";
import path from "path";
// Set fallback environment variable before instantiating client
process.env.DATABASE_URL = process.env.DATABASE_URL || "postgresql://postgres:postgres@127.0.0.1:54322/postgres";
// Load env vars
dotenv.config({ path: path.resolve(process.cwd(), ".env.local") });
const prisma = new PrismaClient();
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
// Initialize Prisma
const prisma = new PrismaClient({ adapter });
async function main() {
console.log('Starting RSVP status migration...');
const guests = await prisma.guest.findMany();
console.log(`Found ${guests.length} guests to migrate.`);
console.log("Starting migration of RSVP status...");
let updatedCount = 0;
try {
// 1. Get all guests
const guests = await prisma.guest.findMany();
console.log(`Found ${guests.length} guests to check.`);
for (const guest of guests) {
let status: RsvpStatus = RsvpStatus.PENDING;
let updatedCount = 0;
// Map legacy isAttending boolean to RsvpStatus enum
if (guest.isAttending === true) {
status = RsvpStatus.ACCEPTED;
} else if (guest.isAttending === false) {
status = RsvpStatus.DECLINED;
}
for (const guest of guests) {
// Skip if already migrated
if (guest.rsvpStatus !== 'PENDING') continue;
// Determine profile completion status
const hasDietary = !!guest.dietaryRestrictions;
// Check if relationships are set (basic heuristic)
const hasRelationship = !!guest.relationship && guest.relationship !== 'Guest';
let newStatus: 'ACCEPTED' | 'DECLINED' | 'PENDING' = 'PENDING';
let completedAt = null;
// Simple logic: if they accepted and have some details, mark profile partially complete?
// Actually, let's leave profileCompleted as false to prompt them to use the new dashboard,
// UNLESS they have filled out a lot of data.
// For now, let's just migrate the RSVP status which is critical.
// Legacy `isAttending` was boolean or null
// If isAttending is true -> ACCEPTED
// If isAttending is false -> DECLINED
// If null -> PENDING
await prisma.guest.update({
where: { id: guest.id },
data: {
rsvpStatus: status,
rsvpCompletedAt: guest.rsvpTimestamp || (status !== RsvpStatus.PENDING ? new Date() : null), // Use existing timestamp or now if missing but replied
// Note: Prisma returns boolean true/false or null
if ((guest as any).isAttending === true) {
newStatus = 'ACCEPTED';
completedAt = (guest as any).rsvpTimestamp || new Date();
} else if ((guest as any).isAttending === false) {
newStatus = 'DECLINED';
completedAt = (guest as any).rsvpTimestamp || new Date();
}
});
updatedCount++;
}
console.log(`Migration complete. Updated ${updatedCount} guests.`);
if (newStatus !== 'PENDING') {
await prisma.guest.update({
where: { id: guest.id },
data: {
rsvpStatus: newStatus,
rsvpCompletedAt: completedAt,
// also set legacy field for consistency? it's already set
}
});
updatedCount++;
process.stdout.write(`.`);
}
}
console.log(`\nMigration complete. Updated ${updatedCount} guests.`);
} catch (error) {
console.error("Migration failed:", error);
} finally {
await prisma.$disconnect();
await pool.end();
}
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
main();

View File

@@ -1,60 +0,0 @@
"use client";
import React, { lazy, Suspense } from "react";
import Link from "next/link";
import { Skeleton } from "@/components/ui/skeleton";
const DomeGalleryContainer = lazy(() =>
import("@/components/features/gallery/dome-gallery-container").then(
(mod) => ({ default: mod.DomeGalleryContainer })
)
);
const GuestUpload = lazy(() =>
import("@/components/features/gallery/guest-upload").then((mod) => ({
default: mod.GuestUpload,
}))
);
export default function GalleryPage() {
// simple trick: change a key to force DomeGalleryContainer to re-run its fetch
const [refreshKey, setRefreshKey] = React.useState(0);
const handleUploadComplete = () => {
setRefreshKey((k) => k + 1);
};
return (
<div className="min-h-screen pb-24 pt-8 px-4 bg-wedding-canvas">
{/* Header */}
<div className="max-w-md mx-auto mb-6 flex items-center gap-4">
<Link
href="/"
className="w-10 h-10 rounded-full bg-wedding-ink/10 dark:bg-wedding-ink/20 flex items-center justify-center hover:bg-wedding-ink/20 dark:hover:bg-wedding-ink/30 transition-colors text-wedding-ink dark:text-wedding-ink"
>
</Link>
<div>
<h1 className="font-heading text-3xl text-wedding-ink">
Shared Dome Gallery
</h1>
<p className="text-xs uppercase tracking-widest text-wedding-ink/50">
The Moyos · Live wedding moments
</p>
</div>
</div>
<main className="mx-auto flex w-full max-w-md flex-col items-center gap-6">
{/* 3D dome (couple + live guest uploads) */}
<section className="w-full h-[420px] rounded-3xl bg-gradient-to-b from-wedding-ink/80 via-wedding-forest/70 to-wedding-ink/90 px-3 py-3 shadow-sage-lg">
<Suspense fallback={<Skeleton className="h-full w-full" />}>
<DomeGalleryContainer key={refreshKey} />
</Suspense>
</section>
{/* Guest upload card */}
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<GuestUpload onUploadComplete={handleUploadComplete} />
</Suspense>
</main>
</div>
);
}

View File

@@ -1,39 +0,0 @@
"use client";
import { GuestbookFeed } from "@/components/features/guestbook/guestbook-feed";
import { useGuest } from "@/context/guest-context";
import Link from "next/link";
export default function GuestbookPage() {
const { guest } = useGuest();
// If someone lands here directly without logging in
if (!guest)
return (
<div className="min-h-screen flex items-center justify-center p-10 text-center bg-wedding-canvas">
<div className="text-wedding-ink dark:text-wedding-ink">Please login first.</div>
</div>
);
return (
<div className="min-h-screen pb-20 pt-8 px-4 bg-wedding-canvas">
{/* Custom Header with Back Button */}
<div className="max-w-2xl mx-auto mb-8 flex items-center gap-4">
<Link
href="/"
className="w-10 h-10 rounded-full bg-wedding-ink/10 dark:bg-wedding-ink/20 flex items-center justify-center hover:bg-wedding-ink/20 dark:hover:bg-wedding-ink/30 transition-colors text-wedding-ink dark:text-wedding-ink"
>
</Link>
<div>
<h1 className="font-heading text-3xl text-wedding-ink">Guestbook</h1>
<p className="text-xs uppercase tracking-widest text-wedding-ink/50">
Messages of Love
</p>
</div>
</div>
<GuestbookFeed />
</div>
);
}

View File

@@ -1,36 +0,0 @@
"use client";
import { lazy, Suspense } from "react";
import Link from "next/link";
import { Skeleton } from "@/components/ui/skeleton";
const SongRequest = lazy(() =>
import("@/components/features/music/song-request").then((mod) => ({
default: mod.SongRequest,
}))
);
export default function MusicPage() {
return (
<div className="min-h-screen pb-20 pt-8 px-4 bg-wedding-canvas">
<div className="max-w-md mx-auto mb-8 flex items-center gap-4">
<Link
href="/"
className="w-10 h-10 rounded-full bg-wedding-ink/10 dark:bg-wedding-ink/20 flex items-center justify-center hover:bg-wedding-ink/20 dark:hover:bg-wedding-ink/30 transition-colors text-wedding-ink dark:text-wedding-ink"
>
</Link>
<div>
<h1 className="font-heading text-3xl text-wedding-ink">DJ Requests</h1>
<p className="text-xs uppercase tracking-widest text-wedding-ink/50">
Set the vibe
</p>
</div>
</div>
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
<SongRequest />
</Suspense>
</div>
);
}

View File

@@ -1,80 +0,0 @@
"use client";
import { Suspense, useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useGuest } from "@/context/guest-context";
import GuestDashboard from "@/components/features/dashboard/dashboard-view";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { CheckCircle2, Info } from "lucide-react";
function DashboardContent() {
const searchParams = useSearchParams();
const router = useRouter();
const { guest, isLoading } = useGuest();
const rsvpStatus = searchParams.get("rsvp");
const guestName = searchParams.get("name");
// Redirect to RSVP if guest hasn't responded
useEffect(() => {
// Wait for guest data to load
if (isLoading) return;
// If no guest, they need to authenticate first (will be handled by auth guard)
if (!guest) return;
// If guest hasn't RSVP'd, redirect to RSVP form
if (!guest.hasResponded && guest.inviteCode) {
router.replace(`/rsvp?invite=${guest.inviteCode}`);
}
}, [guest, isLoading, router]);
// Show loading state while checking
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-16 h-16 rounded-full border-4 border-wedding-sage/20 border-t-wedding-sage animate-spin" />
</div>
);
}
// If guest hasn't responded, don't render dashboard (redirect is in progress)
if (guest && !guest.hasResponded) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-16 h-16 rounded-full border-4 border-wedding-sage/20 border-t-wedding-sage animate-spin" />
</div>
);
}
return (
<>
{rsvpStatus === "already-submitted" && guestName && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4">
<Alert className="bg-wedding-sage/10 border-wedding-sage">
<CheckCircle2 className="h-4 w-4" />
<AlertTitle>Welcome back, {guestName}!</AlertTitle>
<AlertDescription>
You&apos;ve already submitted your RSVP. You can view your
dashboard below.
</AlertDescription>
</Alert>
</div>
)}
<GuestDashboard />
</>
);
}
export default function Page() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="w-16 h-16 rounded-full border-4 border-wedding-sage/20 border-t-wedding-sage animate-spin" />
</div>
}
>
<DashboardContent />
</Suspense>
);
}

View File

@@ -1,36 +0,0 @@
"use client";
import { lazy, Suspense } from "react";
import Link from "next/link";
import { Skeleton } from "@/components/ui/skeleton";
const Timeline = lazy(() =>
import("@/components/features/story/timeline").then((mod) => ({
default: mod.Timeline,
}))
);
export default function StoryPage() {
return (
<div className="min-h-screen pb-20 pt-8 px-4 bg-wedding-canvas">
<div className="max-w-2xl mx-auto mb-8 flex items-center gap-4">
<Link
href="/"
className="w-10 h-10 rounded-full bg-wedding-ink/10 dark:bg-wedding-ink/20 flex items-center justify-center hover:bg-wedding-ink/20 dark:hover:bg-wedding-ink/30 transition-colors text-wedding-ink dark:text-wedding-ink"
>
</Link>
<div>
<h1 className="font-heading text-3xl text-wedding-ink">Our Story</h1>
<p className="text-xs uppercase tracking-widest text-wedding-ink/50">
How it started
</p>
</div>
</div>
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
<Timeline />
</Suspense>
</div>
);
}

View File

@@ -1,72 +0,0 @@
"use client";
import { lazy, Suspense } from "react";
import Link from "next/link";
import { useGuest } from "@/context/guest-context";
import { Skeleton } from "@/components/ui/skeleton";
const WeddingPass = lazy(() =>
import("@/components/features/ticket/wedding-pass").then((mod) => ({
default: mod.WeddingPass,
}))
);
const AddToCalendar = lazy(() =>
import("@/components/features/calendar/add-to-calendar").then((mod) => ({
default: mod.AddToCalendar,
}))
);
export default function TicketPage() {
const { guest } = useGuest();
// Guard clause: If they haven't RSVP'd, kick them out
if (!guest?.hasResponded) {
return (
<div className="min-h-screen flex items-center justify-center text-center p-6 bg-wedding-canvas">
<div>
<h1 className="text-wedding-ink dark:text-wedding-ink text-xl mb-4">Access Denied</h1>
<p className="text-wedding-ink/60 dark:text-wedding-ink/60 mb-6">
Please RSVP to generate your ticket.
</p>
<Link href="/wedding" className="text-wedding-evergreen dark:text-wedding-sage underline">
Back to Info Hub
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen pb-20 pt-8 px-4 bg-wedding-canvas">
{/* Navigation Header */}
<div className="max-w-md mx-auto mb-10 flex items-center gap-4">
<Link
href="/"
className="w-10 h-10 rounded-full bg-wedding-ink/10 dark:bg-wedding-ink/20 flex items-center justify-center hover:bg-wedding-ink/20 dark:hover:bg-wedding-ink/30 transition-colors text-wedding-ink dark:text-wedding-ink"
>
</Link>
<div>
<h1 className="font-heading text-3xl text-wedding-ink dark:text-wedding-ink">Your Pass</h1>
<p className="text-xs uppercase tracking-widest text-wedding-ink/50 dark:text-wedding-ink/50">
Security & Check-in
</p>
</div>
</div>
<div className="flex flex-col items-center gap-6">
{/* 1. The Ticket Visual + Download Button (Handled inside this component) */}
<Suspense fallback={<Skeleton className="h-96 w-full max-w-[340px]" />}>
<WeddingPass />
</Suspense>
{/* 2. The Calendar Button (Placed below the ticket) */}
<div className="w-full max-w-[340px]">
<Suspense fallback={<Skeleton className="h-16 w-full" />}>
<AddToCalendar />
</Suspense>
</div>
</div>
</div>
);
}

View File

@@ -1,38 +0,0 @@
"use client";
import { lazy, Suspense } from "react";
import Link from "next/link";
import { Skeleton } from "@/components/ui/skeleton";
const TributeWall = lazy(() =>
import("@/components/features/tribute/tribute-wall").then((mod) => ({
default: mod.TributeWall,
}))
);
export default function TributePage() {
return (
<div className="min-h-screen pb-20 pt-8 px-4 bg-wedding-canvas">
<div className="max-w-2xl mx-auto mb-8 flex items-center gap-4">
<Link
href="/"
className="w-10 h-10 rounded-full bg-wedding-ink/10 dark:bg-wedding-ink/20 flex items-center justify-center hover:bg-wedding-ink/20 dark:hover:bg-wedding-ink/30 transition-colors text-wedding-ink dark:text-wedding-ink"
>
</Link>
<div>
<h1 className="font-heading text-3xl text-wedding-ink">Tribute Wall</h1>
<p className="text-xs uppercase tracking-widest text-wedding-ink/50">
Forever in our hearts
</p>
</div>
</div>
<div className="max-w-2xl mx-auto">
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
<TributeWall />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useGuest } from "@/context/guest-context";
import { DomeGalleryContainer } from "@/components/features/gallery/dome-gallery-container";
import { GuestUpload } from "@/components/features/gallery/guest-upload";
import { ArrowLeft, Camera } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function DashboardGalleryPage() {
const router = useRouter();
const { guest } = useGuest();
if (!guest) return null;
return (
<div className="space-y-6 max-w-4xl mx-auto pb-20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="p-2 hover:bg-neutral-100 rounded-full transition-colors font-body">
<ArrowLeft className="w-5 h-5 text-wedding-ink" />
</Link>
<h1 className="font-serif text-3xl text-wedding-evergreen">Gallery</h1>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<div className="space-y-6">
<div className="bg-wedding-sage/10 p-4 rounded-lg flex gap-4 items-start border border-wedding-sage/20">
<Camera className="w-6 h-6 text-wedding-sage mt-1" />
<div>
<h3 className="font-medium text-wedding-evergreen">Live Photo Dome 📸</h3>
<p className="text-sm text-muted-foreground mt-1">
Every photo you and other guests upload appears here in real-time. Share your favorite moments from the celebration!
</p>
</div>
</div>
<GuestUpload />
</div>
<div className="lg:h-[calc(100vh-200px)] h-[500px] sticky top-24 rounded-2xl overflow-hidden border shadow-inner bg-wedding-ivory">
<DomeGalleryContainer />
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useGuest } from "@/context/guest-context";
import { Loader2, LogOut, LayoutDashboard, User, Users, MessageSquare, Ticket, Music } from "lucide-react";
import { Loader2, LogOut, LayoutDashboard, User, Users, MessageSquare, Ticket, Music, Flame, BookOpen, Camera, Info } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -60,7 +60,12 @@ export default function DashboardLayout({
<DashboardLink href="/dashboard/profile" icon={User} label="My Profile" />
<DashboardLink href="/dashboard/whos-who" icon={Users} label="Who's Who" />
<DashboardLink href="/dashboard/guestbook" icon={MessageSquare} label="Guestbook" />
<DashboardLink href="/wedding" icon={Ticket} label="Event Info" />
<DashboardLink href="/dashboard/gallery" icon={Camera} label="Gallery" />
<DashboardLink href="/dashboard/music" icon={Music} label="Music" />
<DashboardLink href="/dashboard/story" icon={BookOpen} label="Our Story" />
<DashboardLink href="/dashboard/tribute" icon={Flame} label="Tribute Wall" />
<DashboardLink href="/dashboard/ticket" icon={Ticket} label="My Pass" />
<DashboardLink href="/wedding" icon={Info} label="Event Info" />
</nav>
<div className="pt-6 border-t">
@@ -89,9 +94,10 @@ export default function DashboardLayout({
{/* Mobile Bottom Nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t h-16 flex items-center justify-around px-2 z-50 pb-safe">
<MobileNavLink href="/dashboard" icon={LayoutDashboard} label="Home" />
<MobileNavLink href="/dashboard/profile" icon={User} label="Profile" />
<MobileNavLink href="/dashboard/whos-who" icon={Users} label="Tree" />
<MobileNavLink href="/dashboard/guestbook" icon={MessageSquare} label="Note" />
<MobileNavLink href="/dashboard/ticket" icon={Ticket} label="Pass" />
<MobileNavLink href="/dashboard/gallery" icon={Camera} label="Gallery" />
<MobileNavLink href="/dashboard/music" icon={Music} label="Music" />
<MobileNavLink href="/dashboard/profile" icon={User} label="Me" />
</nav>
</div>
);

View File

@@ -0,0 +1,49 @@
"use client";
import { useGuest } from "@/context/guest-context";
import { CouplesPlaylistDisplay } from "@/components/features/music/couples-playlist-display";
import { SongRequest } from "@/components/features/music/song-request";
import { ArrowLeft, Music } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function DashboardMusicPage() {
const router = useRouter();
const { guest } = useGuest();
if (!guest) return null;
return (
<div className="space-y-6 max-w-4xl mx-auto pb-20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="p-2 hover:bg-neutral-100 rounded-full transition-colors font-body">
<ArrowLeft className="w-5 h-5 text-wedding-ink" />
</Link>
<h1 className="font-serif text-3xl text-wedding-evergreen">Music</h1>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<div className="space-y-6">
<div className="bg-wedding-sage/10 p-4 rounded-lg flex gap-4 items-start border border-wedding-sage/20">
<Music className="w-6 h-6 text-wedding-sage mt-1" />
<div>
<h3 className="font-medium text-wedding-evergreen">Request your favorites 🎵</h3>
<p className="text-sm text-muted-foreground mt-1">
Help us set the mood! Search for your favorite tracks and add them to the request list. You can also vote for songs other guests have recommended.
</p>
</div>
</div>
<SongRequest />
</div>
<div className="space-y-6">
<h2 className="font-serif text-2xl text-wedding-evergreen">Our Curated Playlist</h2>
<CouplesPlaylistDisplay />
</div>
</div>
</div>
);
}

View File

@@ -1,165 +1,7 @@
"use client";
import { useGuest } from "@/context/guest-context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { ArrowRight, UserCircle, CheckCircle2, AlertCircle, Camera, Users, MessageSquare } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import GuestDashboard from "@/components/features/dashboard/dashboard-view";
export default function DashboardPage() {
const { guest } = useGuest();
if (!guest) return null;
// Calculate completion
const checks = [
{
label: "Contact Info",
done: !!(guest.email || guest.phone),
path: "/dashboard/profile"
},
{
label: "Profile Photo",
done: !!guest.avatar || !!(guest as any).profilePhoto, // Check both fields
path: "/dashboard/profile"
},
{
label: "Who's Who",
done: !!guest.whosWhoOptIn || (!!guest.relationship && guest.relationship !== 'Guest'),
path: "/dashboard/whos-who"
},
{
label: "Guestbook Photo",
done: !!guest.guestbookPhotoUrl,
path: "/dashboard/guestbook"
}
];
const completedCount = checks.filter(c => c.done).length;
const total = checks.length;
const percentage = Math.round((completedCount / total) * 100);
return (
<div className="space-y-8">
{/* Welcome Banner */}
<div className="bg-white p-6 rounded-xl border shadow-sm">
<h1 className="font-serif text-3xl text-wedding-evergreen mb-2">
Welcome back, {guest.name.split(' ')[0]}!
</h1>
<p className="text-muted-foreground">
We're counting down the days until we celebrate with you.
</p>
{/* Progress Section */}
<div className="mt-6 p-4 bg-neutral-50 rounded-lg border border-neutral-100">
<div className="flex justify-between items-end mb-2">
<div>
<h3 className="font-medium text-wedding-ink">Profile Completion</h3>
<p className="text-xs text-muted-foreground">Help us personalize your experience</p>
</div>
<span className="font-mono font-bold text-wedding-sage text-lg">{percentage}%</span>
</div>
<Progress value={percentage} className="h-2 mb-4" />
{percentage < 100 && (
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2 text-amber-600">
<AlertCircle className="w-4 h-4" />
Incomplete Items:
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{checks.filter(c => !c.done).map((item) => (
<Link key={item.label} href={item.path} className="flex items-center justify-between p-2 bg-white rounded border text-sm hover:border-wedding-sage transition-colors group">
<span>{item.label}</span>
<ArrowRight className="w-3 h-3 text-muted-foreground group-hover:text-wedding-sage" />
</Link>
))}
</div>
</div>
)}
</div>
</div>
{/* Quick Actions Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* RSVP Status Card */}
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg font-serif text-wedding-evergreen">My RSVP</CardTitle>
<span className={cn(
"px-2 py-1 rounded text-xs font-bold uppercase",
guest.rsvpStatus === 'ACCEPTED' ? "bg-green-100 text-green-700" :
guest.rsvpStatus === 'DECLINED' ? "bg-red-100 text-red-700" :
"bg-yellow-100 text-yellow-700"
)}>
{guest.rsvpStatus || "PENDING"}
</span>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
{guest.rsvpStatus === 'ACCEPTED'
? `You're attending with ${guest.plusOnesCount || 0} guest(s).`
: "You haven't confirmed your attendance yet."}
</p>
<Button variant="outline" size="sm" asChild>
<Link href={`/rsvp/${guest.inviteCode}/step-2-accept-decline`}>Update RSVP</Link>
</Button>
</CardContent>
</Card>
{/* Profile Card */}
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg font-serif text-wedding-evergreen">My Profile</CardTitle>
<UserCircle className="w-5 h-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
manage your contact info, dietary needs, and profile photo.
</p>
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard/profile">Edit Profile</Link>
</Button>
</CardContent>
</Card>
{/* Who's Who Card */}
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg font-serif text-wedding-evergreen">Who's Who</CardTitle>
<Users className="w-5 h-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Share how you know the couple and find friends.
</p>
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard/whos-who">
{guest.whosWhoOptIn ? "Update Entry" : "Join Family Tree"}
</Link>
</Button>
</CardContent>
</Card>
{/* Guestbook Card */}
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg font-serif text-wedding-evergreen">Guestbook</CardTitle>
<MessageSquare className="w-5 h-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
{guest.guestbookMessage ? "Your message is saved." : "Write a note for the couple."}
</p>
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard/guestbook">
{guest.guestbookMessage ? "View/Edit" : "Sign Guestbook"}
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
return <GuestDashboard />;
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useGuest } from "@/context/guest-context";
import { Timeline } from "@/components/features/story/timeline";
import { ArrowLeft, BookOpen } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function DashboardStoryPage() {
const router = useRouter();
const { guest } = useGuest();
if (!guest) return null;
return (
<div className="space-y-6 max-w-4xl mx-auto pb-20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="p-2 hover:bg-neutral-100 rounded-full transition-colors font-body">
<ArrowLeft className="w-5 h-5 text-wedding-ink" />
</Link>
<h1 className="font-serif text-3xl text-wedding-evergreen">Our Story</h1>
</div>
</div>
<div className="max-w-2xl mx-auto space-y-12">
<div className="bg-wedding-sage/10 p-4 rounded-lg flex gap-4 items-start border border-wedding-sage/20">
<BookOpen className="w-6 h-6 text-wedding-sage mt-1" />
<div>
<h3 className="font-medium text-wedding-evergreen">The Journey of Lerato & Denver </h3>
<p className="text-sm text-muted-foreground mt-1">
A look back at the milestones that brought us to this special day. Thank you for being part of our story.
</p>
</div>
</div>
<Timeline />
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useGuest } from "@/context/guest-context";
import { WeddingPass } from "@/components/features/ticket/wedding-pass";
import { ArrowLeft, Ticket } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function DashboardTicketPage() {
const router = useRouter();
const { guest } = useGuest();
if (!guest) return null;
return (
<div className="space-y-6 max-w-4xl mx-auto pb-20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="p-2 hover:bg-neutral-100 rounded-full transition-colors font-body">
<ArrowLeft className="w-5 h-5 text-wedding-ink" />
</Link>
<h1 className="font-serif text-3xl text-wedding-evergreen">My Wedding Pass</h1>
</div>
</div>
<div className="max-w-md mx-auto space-y-8">
<div className="bg-wedding-sage/10 p-4 rounded-lg flex gap-4 items-start border border-wedding-sage/20">
<Ticket className="w-6 h-6 text-wedding-sage mt-1" />
<div>
<h3 className="font-medium text-wedding-evergreen">Your VIP Entry Pass 🎫</h3>
<p className="text-sm text-muted-foreground mt-1">
Download or screenshot this pass. You'll need to present it at the security gate for entry to The Garden Venue.
</p>
</div>
</div>
<WeddingPass />
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { useGuest } from "@/context/guest-context";
import { TributeWall } from "@/components/features/tribute/tribute-wall";
import { ArrowLeft, Flame } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function DashboardTributePage() {
const router = useRouter();
const { guest } = useGuest();
if (!guest) return null;
return (
<div className="space-y-6 max-w-4xl mx-auto pb-20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="p-2 hover:bg-neutral-100 rounded-full transition-colors font-body">
<ArrowLeft className="w-5 h-5 text-wedding-ink" />
</Link>
<h1 className="font-serif text-3xl text-wedding-evergreen">Tribute Wall</h1>
</div>
</div>
<div className="max-w-4xl mx-auto space-y-12">
<TributeWall />
</div>
</div>
);
}

View File

@@ -94,6 +94,8 @@ const itemVariants = {
* Dashboard Sections Mapping - Story Order
* ========================= */
const DASHBOARD_SECTIONS = [
// Act I - Seating (gated)
{ id: "seating", component: LazyTableReveal, label: "Seating", lazy: true },
// Act 2 - The Plan
{ id: "itinerary", component: LazyItineraryTimeline, label: "Schedule", lazy: true },
{ id: "venue", component: LazyVenueMap, label: "Location", lazy: true },
@@ -166,7 +168,7 @@ type NavItem = { kind: "route"; href: string } | { kind: "anchor"; id: string };
export default function GuestDashboard() {
const { guest, logout, isLoading, refresh } = useGuest();
const router = useRouter();
// Track refreshing state to show smooth loading indicators
const [isRefreshingGuest, setIsRefreshingGuest] = React.useState(false);
const refreshTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
@@ -182,7 +184,7 @@ export default function GuestDashboard() {
if (!silent) {
setIsRefreshingGuest(true);
}
try {
if (refresh) {
await refresh();
@@ -205,7 +207,7 @@ export default function GuestDashboard() {
// Realtime updates subscription with debouncing
const refreshDebounceRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (!guest?.id) return;
@@ -220,7 +222,7 @@ export default function GuestDashboard() {
// Subscribe to RSVP updates for this guest
const unsubscribeRSVP = realtimeManager.subscribe('rsvp', (event: any) => {
if (!isMounted) return;
// Check if this update is for the current guest
const eventGuestId = event.data?.guestId || event.data?.id || event.data?.guest?.id;
if (eventGuestId === currentGuestId) {
@@ -228,7 +230,7 @@ export default function GuestDashboard() {
if (refreshDebounceRef.current) {
clearTimeout(refreshDebounceRef.current);
}
// Immediate refresh for RSVP updates (don't debounce too much)
refreshDebounceRef.current = setTimeout(() => {
if (isMounted) {
@@ -241,7 +243,7 @@ export default function GuestDashboard() {
// Subscribe to seating assignment updates for this guest
const unsubscribeSeating = realtimeManager.subscribe('seating', (event: any) => {
if (!isMounted) return;
// Check if this seating assignment is for the current guest
const eventGuestId = event.data?.guestId;
if (eventGuestId === currentGuestId) {
@@ -249,7 +251,7 @@ export default function GuestDashboard() {
if (refreshDebounceRef.current) {
clearTimeout(refreshDebounceRef.current);
}
// Refresh guest data to get updated table assignment
refreshDebounceRef.current = setTimeout(() => {
if (isMounted) {
@@ -297,7 +299,7 @@ export default function GuestDashboard() {
// Debug logging in useEffect only
React.useEffect(() => {
if (!DEBUG) return;
const logData = (location: string, message: string, data: any) => {
// Only log in development if analytics endpoint is configured
if (process.env.NODE_ENV !== 'development' || !process.env.NEXT_PUBLIC_ANALYTICS_ENDPOINT) {
@@ -315,7 +317,7 @@ export default function GuestDashboard() {
runId: 'run1',
hypothesisId: 'D'
})
}).catch(() => {});
}).catch(() => { });
};
logData('dashboard-view.tsx:mount', 'Dashboard component mount', {
@@ -476,7 +478,7 @@ export default function GuestDashboard() {
className="min-h-screen relative pb-32 overflow-x-hidden"
>
{/* Background blobs */}
<div className="relative z-10 container max-w-lg mx-auto pt-8 px-4">
{/* Header */}
@@ -512,12 +514,12 @@ export default function GuestDashboard() {
{/* Profile Avatar */}
<div className="h-8 w-8 sm:h-10 sm:w-10 flex items-center justify-center rounded-full hover:bg-white/70 transition-all overflow-hidden border border-wedding-sage/20">
<Avatar className="h-full w-full">
<AvatarImage
src={displayGuest?.avatar || undefined}
alt={displayGuest?.name ? `${displayGuest.name}'s avatar` : 'Guest avatar'}
<AvatarImage
src={displayGuest?.avatar || undefined}
alt={displayGuest?.name ? `${displayGuest.name}'s avatar` : 'Guest avatar'}
/>
<AvatarFallback className="bg-wedding-sage/10 text-wedding-evergreen text-xs sm:text-sm font-medium">
{displayGuest?.name
{displayGuest?.name
? displayGuest.name.charAt(0).toUpperCase() + ((displayGuest as any).surname?.charAt(0) || '').toUpperCase()
: 'G'
}
@@ -560,7 +562,7 @@ export default function GuestDashboard() {
try {
const hasResponded = displayGuest?.hasResponded;
const isAttending = displayGuest?.isAttending === true;
if (hasResponded && isAttending) {
// Only show ticket if attending
router.push("/dashboard/ticket");
@@ -777,7 +779,7 @@ export default function GuestDashboard() {
We can't wait to share this day with you...
</p>
<p className="text-base text-wedding-ink/70 leading-relaxed max-w-md mx-auto">
Your presence will make our celebration complete.
Your presence will make our celebration complete.
Thank you for being part of our story.
</p>
<div className="pt-6">
@@ -820,7 +822,7 @@ const RSVPStatusCard = ({
const hasResponded = !!guest?.hasResponded;
const isAttending = guest?.isAttending === true;
const isNotAttending = guest?.isAttending === false;
// Status determination with explicit checks
let status: 'pending' | 'attending' | 'declined';
if (!hasResponded || guest?.isAttending === null || guest?.isAttending === undefined) {
@@ -832,10 +834,10 @@ const RSVPStatusCard = ({
} else {
status = 'pending'; // Fallback
}
const prevStatus = React.useRef(status);
const [isAnimating, setIsAnimating] = React.useState(false);
// Track state changes for smooth transitions
React.useEffect(() => {
if (prevStatus.current !== status) {
@@ -962,7 +964,7 @@ const RSVPStatusCard = ({
RSVP Status
</span>
</div>
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
@@ -974,13 +976,13 @@ const RSVPStatusCard = ({
status === 'declined' && "bg-wedding-ink/5"
)}
>
<StatusIcon
<StatusIcon
className={cn(
"h-6 w-6",
status === 'attending' && "text-wedding-sage",
status === 'pending' && "text-amber-500",
status === 'declined' && "text-wedding-ink/60"
)}
)}
/>
</motion.div>
</div>
@@ -1108,7 +1110,7 @@ const RSVPStatusCard = ({
const DashboardFloatingNav = () => {
const router = useRouter();
const [isConciergeOpen, setIsConciergeOpen] = React.useState(false);
const navItems: FloatingNavItem[] = React.useMemo(
() => [
{ id: "home", label: "Home", icon: Home },
@@ -1148,7 +1150,7 @@ const DashboardFloatingNav = () => {
/>
<AnimatePresence>
{isConciergeOpen && (
<LazyWeddingConcierge
<LazyWeddingConcierge
isOpen={isConciergeOpen}
onClose={() => setIsConciergeOpen(false)}
/>

File diff suppressed because it is too large Load Diff

View File

@@ -61,11 +61,11 @@ function getPrismaInstance(): any {
if (typeof process === 'undefined') {
throw new Error('Prisma client can only be used in server-side code with Node.js environment.');
}
if (!process.env) {
throw new Error('Prisma client can only be used in server-side code with Node.js environment.');
}
// CRITICAL: Check for DATABASE_URL - this is where the error occurs
// We must check this AFTER confirming we're in a proper Node.js runtime
// During bundling, process.env might exist but not have the actual values
@@ -74,7 +74,7 @@ function getPrismaInstance(): any {
// During bundling, this should never be called, but if it is, throw a clear error
throw new Error('Prisma client cannot be initialized during bundling. This should never be called at build time.');
}
// Additional check: if DATABASE_URL is not set, check if we're in a bundling context
const databaseUrl = process.env.RUNTIME_DATABASE_URL || process.env.DATABASE_URL;
if (!databaseUrl) {
@@ -90,14 +90,14 @@ function getPrismaInstance(): any {
// Lazy require PrismaClient - only happens at runtime, not during bundling
// Import at the top level but guard with runtime checks
let PrismaClient: any;
try {
// Use a static import path but only execute at runtime
// This avoids Turbopack's dynamic require issues
const prismaClientModule = '@prisma/client';
const prismaModule = require(prismaClientModule);
PrismaClient = prismaModule.PrismaClient;
if (!PrismaClient) {
throw new Error('PrismaClient not found in @prisma/client module');
}
@@ -106,11 +106,12 @@ function getPrismaInstance(): any {
throw new Error(`Prisma client could not be loaded: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Try to use PrismaPg adapter if available
let prismaAdapter: any = undefined;
// Start Adapter (Required for engineType="client")
let prismaAdapter: any;
try {
const { PrismaPg } = require('@prisma/adapter-pg');
const { Pool } = require('pg');
// Configure Pool
const pool = new Pool({
connectionString: databaseUrl,
max: Number(process.env.PG_POOL_MAX || 10),
@@ -119,17 +120,16 @@ function getPrismaInstance(): any {
});
prismaAdapter = new PrismaPg(pool);
} catch (error) {
// Adapter not available - will use default connection
console.warn('PrismaPg adapter not available, using default connection:', error);
console.error('Failed to initialize Prisma adapter:', error);
throw new Error(`Prisma adapter initialization failed: ${error instanceof Error ? error.message : String(error)}`);
}
const isDevelopment = process.env.NODE_ENV === 'development';
// Create PrismaClient with adapter if available
// Create PrismaClient with adapter (Required)
const instance = new PrismaClient({
log: isDevelopment ? ['query', 'error', 'warn'] : ['error'],
// Pass the adapter if it was successfully created
...(prismaAdapter && { adapter: prismaAdapter }),
adapter: prismaAdapter,
});
if (isDevelopment) {
@@ -152,58 +152,58 @@ function createModelProxy(modelName: string): any {
if (typeof window !== 'undefined') {
throw new Error('Prisma client cannot be used in client components. Use API routes instead.');
}
if (typeof process === 'undefined' || !process.env) {
throw new Error('Prisma client can only be used in server-side code with Node.js environment.');
}
// Initialize Prisma and get the model
const instance = getPrismaInstance();
// Prisma client uses camelCase for model names (e.g., "Tribute" -> "tribute")
// The modelName parameter is already in camelCase from the proxy getter
// Try the provided name first (most common case)
let model = (instance as any)[modelName];
// If model not found, try variations for case sensitivity
if (!model) {
// Try lowercase if provided name has uppercase
if (modelName !== modelName.toLowerCase()) {
model = (instance as any)[modelName.toLowerCase()];
}
// Try with first letter capitalized (schema convention)
if (!model) {
const capitalized = modelName.charAt(0).toUpperCase() + modelName.slice(1);
model = (instance as any)[capitalized];
}
}
if (!model) {
// Provide helpful error message with available models
// Filter out internal Prisma properties and methods
const availableModels = Object.keys(instance).filter(key =>
!key.startsWith('$') &&
!key.startsWith('_') &&
const availableModels = Object.keys(instance).filter(key =>
!key.startsWith('$') &&
!key.startsWith('_') &&
typeof (instance as any)[key] === 'object' &&
(instance as any)[key] !== null
);
// Check if this might be a Next.js cache issue
const errorMsg = `Prisma model '${modelName}' not found. ` +
`Available models: ${availableModels.length > 0 ? availableModels.join(', ') : 'none'}. ` +
`If you recently added this model, try: 1) Restart the Next.js dev server, 2) Run 'npx prisma generate', 3) Clear .next cache`;
throw new Error(errorMsg);
}
const method = model[methodName];
if (typeof method === 'function') {
// Call the Prisma method with the provided arguments
return method.apply(model, args);
}
// Return the property value if it's not a function
return method;
};
@@ -220,9 +220,9 @@ export const prisma = new Proxy({} as any, {
// CRITICAL: This getter is called during module analysis/bundling
// We must return a Proxy immediately without ANY server-side code execution
// The Proxy will only initialize Prisma when actually invoked at runtime
const propStr = String(prop);
// For Prisma models (anything that's not a special Prisma property starting with $)
// Return a nested Proxy that will handle method calls at runtime
// This works for all models dynamically without hardcoding names
@@ -231,7 +231,7 @@ export const prisma = new Proxy({} as any, {
// Prisma client uses camelCase for model names (e.g., "Tribute" -> "tribute")
return createModelProxy(propStr);
}
// For special Prisma properties (like $connect, $disconnect, etc.), return a function stub
return function prismaStub(...args: any[]) {
// This function is only called at actual runtime, not during bundling
@@ -239,20 +239,20 @@ export const prisma = new Proxy({} as any, {
if (typeof window !== 'undefined') {
throw new Error('Prisma client cannot be used in client components. Use API routes instead.');
}
// Check if we're in a proper Node.js runtime
if (typeof process === 'undefined' || !process.env) {
throw new Error('Prisma client can only be used in server-side code with Node.js environment.');
}
// Only now, at actual runtime, do we initialize Prisma
const instance = getPrismaInstance();
const method = (instance as any)[prop];
if (typeof method === 'function') {
return method.apply(instance, args);
}
return method;
};
},

View File

@@ -10,7 +10,15 @@ export interface Guest {
phone?: string;
dietaryRestrictions?: string;
rsvpTimestamp?: string;
avatar?: string; // Profile picture uploaded during RSVP (when socialOptIn is true)
avatar?: string;
profilePhoto?: string; // Fallback for avatar
rsvpStatus?: 'ACCEPTED' | 'DECLINED' | 'PENDING';
whosWhoOptIn?: boolean;
relationship?: string;
relationshipDetails?: string;
guestbookMessage?: string;
guestbookPhotoUrl?: string;
plusOnesCount?: number;
// New Optional Field
table?: {
id: number;