diff --git a/next.config.ts b/next.config.ts
index 76dde13..abc52f4 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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 [
diff --git a/scripts/migrate-rsvp-status.ts b/scripts/migrate-rsvp-status.ts
index 6821bc0..41b3e9c 100644
--- a/scripts/migrate-rsvp-status.ts
+++ b/scripts/migrate-rsvp-status.ts
@@ -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();
diff --git a/src/app/(auth)/dashboard/gallery/page.tsx b/src/app/(auth)/dashboard/gallery/page.tsx
deleted file mode 100644
index 271c796..0000000
--- a/src/app/(auth)/dashboard/gallery/page.tsx
+++ /dev/null
@@ -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 (
-
- {/* Header */}
-
-
- ←
-
-
-
- Shared Dome Gallery
-
-
- The Moyos · Live wedding moments
-
-
-
-
-
- {/* 3D dome (couple + live guest uploads) */}
-
-
- {/* Guest upload card */}
- }>
-
-
-
-
- );
-}
diff --git a/src/app/(auth)/dashboard/guestbook/page.tsx b/src/app/(auth)/dashboard/guestbook/page.tsx
deleted file mode 100644
index 1d3d279..0000000
--- a/src/app/(auth)/dashboard/guestbook/page.tsx
+++ /dev/null
@@ -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 (
-
- );
-
- return (
-
- {/* Custom Header with Back Button */}
-
-
- ←
-
-
-
Guestbook
-
- Messages of Love
-
-
-
-
-
-
- );
-}
diff --git a/src/app/(auth)/dashboard/music/page.tsx b/src/app/(auth)/dashboard/music/page.tsx
deleted file mode 100644
index 01034e7..0000000
--- a/src/app/(auth)/dashboard/music/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
- ←
-
-
-
DJ Requests
-
- Set the vibe
-
-
-
-
-
}>
-
-
-
- );
-}
diff --git a/src/app/(auth)/dashboard/page.tsx b/src/app/(auth)/dashboard/page.tsx
deleted file mode 100644
index 1774691..0000000
--- a/src/app/(auth)/dashboard/page.tsx
+++ /dev/null
@@ -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 (
-
- );
- }
-
- // If guest hasn't responded, don't render dashboard (redirect is in progress)
- if (guest && !guest.hasResponded) {
- return (
-
- );
- }
-
- return (
- <>
- {rsvpStatus === "already-submitted" && guestName && (
-
-
-
- Welcome back, {guestName}!
-
- You've already submitted your RSVP. You can view your
- dashboard below.
-
-
-
- )}
-
- >
- );
-}
-
-export default function Page() {
- return (
-
-
-
- }
- >
-
-
- );
-}
diff --git a/src/app/(auth)/dashboard/story/page.tsx b/src/app/(auth)/dashboard/story/page.tsx
deleted file mode 100644
index 3b45ce6..0000000
--- a/src/app/(auth)/dashboard/story/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
- ←
-
-
-
Our Story
-
- How it started
-
-
-
-
-
}>
-
-
-
- );
-}
diff --git a/src/app/(auth)/dashboard/ticket/page.tsx b/src/app/(auth)/dashboard/ticket/page.tsx
deleted file mode 100644
index 6f9be83..0000000
--- a/src/app/(auth)/dashboard/ticket/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
Access Denied
-
- Please RSVP to generate your ticket.
-
-
- Back to Info Hub
-
-
-
- );
- }
-
- return (
-
- {/* Navigation Header */}
-
-
- ←
-
-
-
Your Pass
-
- Security & Check-in
-
-
-
-
-
- {/* 1. The Ticket Visual + Download Button (Handled inside this component) */}
-
}>
-
-
-
- {/* 2. The Calendar Button (Placed below the ticket) */}
-
-
-
- );
-}
diff --git a/src/app/(auth)/dashboard/tribute/page.tsx b/src/app/(auth)/dashboard/tribute/page.tsx
deleted file mode 100644
index 7e54f20..0000000
--- a/src/app/(auth)/dashboard/tribute/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
- ←
-
-
-
Tribute Wall
-
- Forever in our hearts
-
-
-
-
-
- }>
-
-
-
-
- );
-}
diff --git a/src/app/(dashboard)/dashboard/gallery/page.tsx b/src/app/(dashboard)/dashboard/gallery/page.tsx
new file mode 100644
index 0000000..6aa9715
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/gallery/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
Live Photo Dome 📸
+
+ Every photo you and other guests upload appears here in real-time. Share your favorite moments from the celebration!
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/layout.tsx b/src/app/(dashboard)/dashboard/layout.tsx
index 792158c..8a65508 100644
--- a/src/app/(dashboard)/dashboard/layout.tsx
+++ b/src/app/(dashboard)/dashboard/layout.tsx
@@ -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({
-
+
+
+
+
+
+
@@ -89,9 +94,10 @@ export default function DashboardLayout({
{/* Mobile Bottom Nav */}
-
-
-
+
+
+
+
);
diff --git a/src/app/(dashboard)/dashboard/music/page.tsx b/src/app/(dashboard)/dashboard/music/page.tsx
new file mode 100644
index 0000000..717afbc
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/music/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
Request your favorites 🎵
+
+ 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.
+
+
+
+
+
+
+
+
+
Our Curated Playlist
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx
index fa9a45b..2d38718 100644
--- a/src/app/(dashboard)/dashboard/page.tsx
+++ b/src/app/(dashboard)/dashboard/page.tsx
@@ -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 (
-
- {/* Welcome Banner */}
-
-
- Welcome back, {guest.name.split(' ')[0]}!
-
-
- We're counting down the days until we celebrate with you.
-
-
- {/* Progress Section */}
-
-
-
-
Profile Completion
-
Help us personalize your experience
-
-
{percentage}%
-
-
-
- {percentage < 100 && (
-
-
-
- Incomplete Items:
-
-
- {checks.filter(c => !c.done).map((item) => (
-
-
{item.label}
-
-
- ))}
-
-
- )}
-
-
-
- {/* Quick Actions Grid */}
-
- {/* RSVP Status Card */}
-
-
- My RSVP
-
- {guest.rsvpStatus || "PENDING"}
-
-
-
-
- {guest.rsvpStatus === 'ACCEPTED'
- ? `You're attending with ${guest.plusOnesCount || 0} guest(s).`
- : "You haven't confirmed your attendance yet."}
-
-
- Update RSVP
-
-
-
-
- {/* Profile Card */}
-
-
- My Profile
-
-
-
-
- manage your contact info, dietary needs, and profile photo.
-
-
- Edit Profile
-
-
-
-
- {/* Who's Who Card */}
-
-
- Who's Who
-
-
-
-
- Share how you know the couple and find friends.
-
-
-
- {guest.whosWhoOptIn ? "Update Entry" : "Join Family Tree"}
-
-
-
-
-
- {/* Guestbook Card */}
-
-
- Guestbook
-
-
-
-
- {guest.guestbookMessage ? "Your message is saved." : "Write a note for the couple."}
-
-
-
- {guest.guestbookMessage ? "View/Edit" : "Sign Guestbook"}
-
-
-
-
-
-
- );
+ return ;
}
diff --git a/src/app/(dashboard)/dashboard/story/page.tsx b/src/app/(dashboard)/dashboard/story/page.tsx
new file mode 100644
index 0000000..62be4a6
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/story/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
The Journey of Lerato & Denver ✨
+
+ A look back at the milestones that brought us to this special day. Thank you for being part of our story.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/ticket/page.tsx b/src/app/(dashboard)/dashboard/ticket/page.tsx
new file mode 100644
index 0000000..d530c67
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/ticket/page.tsx
@@ -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 (
+
+
+
+
+
+
+
My Wedding Pass
+
+
+
+
+
+
+
+
Your VIP Entry Pass 🎫
+
+ Download or screenshot this pass. You'll need to present it at the security gate for entry to The Garden Venue.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/tribute/page.tsx b/src/app/(dashboard)/dashboard/tribute/page.tsx
new file mode 100644
index 0000000..d9104be
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/tribute/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/features/dashboard/dashboard-view.tsx b/src/components/features/dashboard/dashboard-view.tsx
index d1e31c1..c71cc31 100644
--- a/src/components/features/dashboard/dashboard-view.tsx
+++ b/src/components/features/dashboard/dashboard-view.tsx
@@ -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(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(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 */}
-
+
{/* Header */}
@@ -512,12 +514,12 @@ export default function GuestDashboard() {
{/* Profile Avatar */}
-
- {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...
- Your presence will make our celebration complete.
+ Your presence will make our celebration complete.
Thank you for being part of our story.
@@ -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
-
+
-
@@ -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 = () => {
/>
{isConciergeOpen && (
- setIsConciergeOpen(false)}
/>
diff --git a/src/components/features/rsvp/enhanced-rsvp-form.tsx b/src/components/features/rsvp/enhanced-rsvp-form.tsx
index 1363794..e8087e3 100644
--- a/src/components/features/rsvp/enhanced-rsvp-form.tsx
+++ b/src/components/features/rsvp/enhanced-rsvp-form.tsx
@@ -195,35 +195,18 @@ const RELATIONSHIP_OPTIONS = {
],
};
-const profileSchema = z.object({
+const detailsSchema = z.object({
// Relationship fields (mandatory)
relationship: z.string().min(1, "Please select or enter your relationship"),
relationshipTo: z.enum(["groom", "bride"], {
message: "Please select who you're related to",
}),
relationshipCustom: z.string().optional(), // For custom relationship text
-
- // Contact info (optional with privacy controls)
- phone: z.string().optional().or(z.literal("")),
- phonePublic: z.boolean(),
- email: z.string().email("Please enter a valid email address").optional().or(z.literal("")),
- emailPublic: z.boolean(),
-
- // Social media (optional with privacy controls)
- facebook: z.string().optional().or(z.literal("")),
- facebookPublic: z.boolean(),
- instagram: z.string().optional().or(z.literal("")),
- instagramPublic: z.boolean(),
- linkedin: z.string().optional().or(z.literal("")),
- linkedinPublic: z.boolean(),
-
- // Social directory opt-in
- socialOptIn: z.boolean(),
- socialBio: z.string().max(160, "Bio must be 160 characters or less").optional().or(z.literal("")),
- socialAvatar: z.string().optional().or(z.literal("")),
-
- // Email notifications opt-in (optional)
- emailNotifications: z.boolean(),
+
+ // Party Details
+ pax: z.number().min(1, "Please specify number of guests").max(10, "Maximum 10 guests"),
+ dietary: z.string().optional(),
+ specialNeeds: z.string().optional(),
}).refine((data) => {
// If relationship is "Custom", relationshipCustom must be provided
if (data.relationship === "Custom") {
@@ -233,21 +216,6 @@ const profileSchema = z.object({
}, {
message: "Please enter your custom relationship",
path: ["relationshipCustom"],
-}).refine((data) => {
- // If social opt-in is true, bio is optional but if provided must be valid
- if (data.socialOptIn && data.socialBio) {
- return data.socialBio.length <= 160;
- }
- return true;
-}, {
- message: "Bio must be 160 characters or less",
- path: ["socialBio"],
-});
-
-const detailsSchema = z.object({
- pax: z.number().min(1, "Please specify number of guests").max(10, "Maximum 10 guests"),
- dietary: z.string().optional(),
- specialNeeds: z.string().optional(),
});
const guestbookSchema = z.object({
@@ -257,7 +225,6 @@ const guestbookSchema = z.object({
// Combined schema with dietary requirement validation
const fullFormSchema = attendanceSchema
- .and(profileSchema)
.and(detailsSchema)
.and(guestbookSchema)
.refine((data) => {
@@ -372,19 +339,14 @@ export function EnhancedRsvpForm() {
const fileInputRef = React.useRef(null);
const [step, setStep] = React.useState<
- "rules" | "attendance" | "profile" | "details" | "guestbook" | "success"
- >("rules");
+ "attendance" | "details" | "guestbook" | "success"
+ >("attendance");
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [hasAcknowledged, setHasAcknowledged] = React.useState(false);
const [photoPreview, setPhotoPreview] = React.useState(
(guest as any)?.profilePhoto || null
);
- const [socialAvatarPreview, setSocialAvatarPreview] = React.useState(
- guest?.avatar || null
- );
- const socialAvatarInputRef = React.useRef(null);
-
// Database status state
const [dbStatus, setDbStatus] = React.useState({
isLoading: true,
@@ -399,16 +361,16 @@ export function EnhancedRsvpForm() {
// Create manual timeout instead of AbortSignal.timeout (not supported in all runtimes)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
-
+
const res = await fetch('/api/health', {
method: 'GET',
credentials: 'include',
cache: 'no-store',
signal: controller.signal,
});
-
+
clearTimeout(timeoutId);
-
+
// Safely parse health check response
let data: any = { status: 'error', services: {} };
try {
@@ -421,12 +383,12 @@ export function EnhancedRsvpForm() {
// Default to error state if parsing fails
data = { status: 'error', services: {} };
}
-
+
// Check if database service is healthy (database is critical, other services are optional)
const dbHealthy = data?.services?.database?.status === 'healthy';
// Overall status should be ok or degraded (not error) for database to be usable
const overallOk = data?.status === 'ok' || data?.status === 'degraded';
-
+
setDbStatus({
isLoading: false,
isOnline: navigator.onLine,
@@ -454,25 +416,11 @@ export function EnhancedRsvpForm() {
relationship: (guest as any)?.relationship || "",
relationshipTo: undefined,
relationshipCustom: "",
- phone: guest?.phone || "",
- phonePublic: false as boolean,
- email: guest?.email || "",
- emailPublic: false as boolean,
- facebook: "",
- facebookPublic: false as boolean,
- instagram: "",
- instagramPublic: false as boolean,
- linkedin: "",
- linkedinPublic: false as boolean,
pax: guest?.maxPax || 1,
dietary: "",
specialNeeds: "",
guestbookMessage: "",
profilePhoto: (guest as any)?.profilePhoto || "",
- socialOptIn: false as boolean,
- socialBio: (guest as any)?.bio || "",
- socialAvatar: guest?.avatar || "",
- emailNotifications: false as boolean,
},
mode: "onChange", // Validate on change for better UX
});
@@ -489,7 +437,7 @@ export function EnhancedRsvpForm() {
}
}, [step, form, router]);
- const steps = ["rules", "attendance", "profile", "details", "guestbook", "success"];
+ const steps = ["attendance", "details", "guestbook", "success"];
const currentIndex = steps.indexOf(step);
const progress = ((currentIndex + 1) / steps.length) * 100;
@@ -506,28 +454,7 @@ export function EnhancedRsvpForm() {
}
};
- const handleSocialAvatarChange = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (file) {
- // Validate file size (max 5MB)
- if (file.size > 5 * 1024 * 1024) {
- setSubmitError('Avatar image must be less than 5MB');
- return;
- }
- // Validate file type
- if (!file.type.startsWith('image/')) {
- setSubmitError('Please upload an image file');
- return;
- }
- const reader = new FileReader();
- reader.onloadend = () => {
- const result = reader.result as string;
- setSocialAvatarPreview(result);
- form.setValue("socialAvatar", result);
- };
- reader.readAsDataURL(file);
- }
- };
+
const [submitError, setSubmitError] = React.useState(null);
@@ -543,7 +470,7 @@ export function EnhancedRsvpForm() {
setStep('attendance'); // Navigate back to attendance step
return;
}
-
+
setIsSubmitting(true);
setSubmitError(null);
@@ -556,7 +483,7 @@ export function EnhancedRsvpForm() {
credentials: 'include',
cache: 'no-store',
});
-
+
if (!authCheck.ok) {
console.warn('Auth check failed, attempting to re-authenticate...');
// For DEV001, try to re-authenticate
@@ -567,13 +494,13 @@ export function EnhancedRsvpForm() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode: 'DEV001' }),
});
-
+
if (!reloadResponse.ok) {
setSubmitError('Failed to authenticate. Please refresh the page and try again.');
setIsSubmitting(false);
return;
}
-
+
// Wait for cookie to be set
await new Promise(resolve => setTimeout(resolve, 200));
} else {
@@ -598,7 +525,7 @@ export function EnhancedRsvpForm() {
credentials: 'include', // Important: send cookies for session
cache: 'no-store', // Ensure fresh token
});
-
+
if (csrfResponse.ok) {
// Safely parse CSRF token response
try {
@@ -616,7 +543,7 @@ export function EnhancedRsvpForm() {
const errorText = await csrfResponse.text();
const { logger } = require('@/lib/logger');
logger.warn('CSRF token fetch failed', { status: csrfResponse.status, error: errorText });
-
+
// For dev mode with DEV001, try to re-authenticate if session is missing
if (guest?.inviteCode === 'DEV001' && csrfResponse.status === 401) {
// Dev mode - attempting re-authentication
@@ -628,11 +555,11 @@ export function EnhancedRsvpForm() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode: 'DEV001' }),
});
-
+
if (reloadResponse.ok) {
// Wait a bit for cookie to be set
await new Promise(resolve => setTimeout(resolve, 200));
-
+
// Retry CSRF token fetch
const retryResponse = await fetch('/api/csrf', {
method: 'GET',
@@ -689,7 +616,7 @@ export function EnhancedRsvpForm() {
const response = await fetch('/api/rsvp/submit', {
method: 'POST',
credentials: 'include', // Important: send cookies for session
- headers: {
+ headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
@@ -701,20 +628,7 @@ export function EnhancedRsvpForm() {
dietaryRestrictions: data.dietary || null,
relationship: data.relationshipCustom || data.relationship || null,
relationshipTo: data.relationshipTo || null,
- phone: data.phone || null,
- phonePublic: data.phonePublic || false,
- email: data.email || null,
- emailPublic: data.emailPublic || false,
- facebook: data.facebook || null,
- facebookPublic: data.facebookPublic || false,
- instagram: data.instagram || null,
- instagramPublic: data.instagramPublic || false,
- linkedin: data.linkedin || null,
- linkedinPublic: data.linkedinPublic || false,
- socialOptIn: data.socialOptIn || false,
- bio: data.socialOptIn ? (data.socialBio || null) : null,
- avatar: data.socialOptIn ? (data.socialAvatar || null) : null,
- emailNotifications: data.emailNotifications || false,
+ // Defer contact/social info to dashboard
uuid: getOrCreateUUID(),
}),
});
@@ -723,7 +637,7 @@ export function EnhancedRsvpForm() {
// Safely parse error response - handle empty or non-JSON responses
let errorData: any = { error: 'An error occurred' };
const contentType = response.headers.get('content-type');
-
+
try {
const responseText = await response.text();
if (responseText && contentType?.includes('application/json')) {
@@ -737,7 +651,7 @@ export function EnhancedRsvpForm() {
console.error('Failed to parse error response:', parseError);
errorData = { error: `Server error (${response.status})` };
}
-
+
// Handle duplicate RSVP submission
if (response.status === 409 && errorData.alreadySubmitted) {
setSubmitError('You have already submitted your RSVP. Redirecting...');
@@ -765,14 +679,14 @@ export function EnhancedRsvpForm() {
setSubmitError('Invalid invite code. Please check your invitation link.');
return;
}
-
+
throw new Error(errorData.error || 'Failed to submit RSVP');
}
// Safely parse success response
let result: any = {};
const contentType = response.headers.get('content-type');
-
+
try {
const responseText = await response.text();
if (responseText && contentType?.includes('application/json')) {
@@ -805,7 +719,7 @@ export function EnhancedRsvpForm() {
// Add small delay to ensure database is fully updated
await new Promise(resolve => setTimeout(resolve, 300));
const updatedGuest = refresh ? await refresh() : null;
-
+
if (!updatedGuest) {
// Fallback: update local state with API response
const isAttendingValue = data.attendance === "yes";
@@ -859,7 +773,15 @@ export function EnhancedRsvpForm() {
const errors: string[] = [];
const formErrors = form.formState.errors;
- if (step === "profile") {
+ if (step === "attendance") {
+ if (formErrors.attendance) {
+ errors.push(formErrors.attendance.message as string);
+ }
+ if (!hasAcknowledged) {
+ errors.push("Please acknowledge the house rules");
+ }
+ } else if (step === "details") {
+ // Validate relationship fields in details step
if (!form.watch("relationshipTo")) {
errors.push("Please select who you're related to");
}
@@ -869,6 +791,13 @@ export function EnhancedRsvpForm() {
if (form.watch("relationship") === "Custom" && !form.watch("relationshipCustom")?.trim()) {
errors.push("Please enter your custom relationship");
}
+ if (!form.watch("pax") || form.watch("pax") < 1) {
+ errors.push("Please specify number of guests");
+ }
+ if (!form.watch("dietary")?.trim()) {
+ errors.push("Dietary requirements are required when attending");
+ }
+
// Check form errors
if (formErrors.relationshipTo) {
errors.push(formErrors.relationshipTo.message as string);
@@ -879,14 +808,6 @@ export function EnhancedRsvpForm() {
if (formErrors.relationshipCustom) {
errors.push(formErrors.relationshipCustom.message as string);
}
- } else if (step === "details") {
- if (!form.watch("pax") || form.watch("pax") < 1) {
- errors.push("Please specify number of guests");
- }
- if (!form.watch("dietary")?.trim()) {
- errors.push("Dietary requirements are required when attending");
- }
- // Check form errors
if (formErrors.pax) {
errors.push(formErrors.pax.message as string);
}
@@ -910,23 +831,18 @@ export function EnhancedRsvpForm() {
// Check if current step is valid
const isStepValid = (): boolean => {
- if (step === "rules") {
- return hasAcknowledged;
- }
if (step === "attendance") {
- return !!form.watch("attendance");
+ return !!form.watch("attendance") && hasAcknowledged;
}
- if (step === "profile") {
+ if (step === "details") {
const relationshipTo = form.watch("relationshipTo");
const relationship = form.watch("relationship");
const relationshipCustom = form.watch("relationshipCustom");
- if (!relationshipTo || !relationship) return false;
- if (relationship === "Custom" && !relationshipCustom?.trim()) return false;
- return true;
- }
- if (step === "details") {
const pax = form.watch("pax");
const dietary = form.watch("dietary");
+
+ if (!relationshipTo || !relationship) return false;
+ if (relationship === "Custom" && !relationshipCustom?.trim()) return false;
if (!pax || pax < 1) return false;
if (!dietary?.trim()) return false;
return true;
@@ -939,33 +855,16 @@ export function EnhancedRsvpForm() {
};
const goForward = async () => {
- if (step === "rules") {
- // From rules, always go to attendance
- if (hasAcknowledged) {
- setStep("attendance");
- }
- return;
- }
-
+
+
if (currentIndex < steps.length - 1) {
// Skip details only if "no" (dietary required for "yes")
if (step === "attendance" && form.watch("attendance") === "no") {
- // Skip to profile if declining, but still need profile
- setStep("profile");
- } else if (step === "profile") {
- // Validate profile step before proceeding
- const isValid = await form.trigger(["relationship", "relationshipTo"]);
- if (isValid) {
- // Skip details if not attending
- if (form.watch("attendance") === "no") {
- setStep("guestbook");
- } else {
- setStep(steps[currentIndex + 1] as any);
- }
- }
+ // Skip details (which includes relationship, pax, diet) and go to guestbook
+ setStep("guestbook");
} else if (step === "details") {
// Validate details step before proceeding
- const isValid = await form.trigger(["pax", "dietary"]);
+ const isValid = await form.trigger(["relationship", "relationshipTo", "pax", "dietary"]);
if (isValid) {
setStep(steps[currentIndex + 1] as any);
}
@@ -998,8 +897,8 @@ export function EnhancedRsvpForm() {
dbStatus.isLoading
? "border-wedding-sage/20 text-wedding-moss/70"
: dbStatus.isOnline && dbStatus.isHealthy
- ? "border-green-500/30 text-green-700"
- : "border-orange-500/30 text-orange-700"
+ ? "border-green-500/30 text-green-700"
+ : "border-orange-500/30 text-orange-700"
)}
>
{dbStatus.isLoading ? (
@@ -1043,303 +942,252 @@ export function EnhancedRsvpForm() {
+
+
+
+
+
Your Attendance
+
{
+ const handleAttendanceChange = (value: string) => {
+ field.onChange(value);
+ };
+
+ return (
+
+
+
-
-
+
+
-
-
-
-
- Regretfully Decline
-
-
- We'll miss having you there
-
-
-
- {field.value === "no" && (
- <>
-
-
-
-
- 😢
-
-
- 💔
-
- {/* Tears animation - fixed positioning */}
+
+
+
+
+ {field.value === "yes" && (
+ <>
+
+
+
+
+ 🎉
+
+
+ 🎊
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
{field.value === "no" && (
-
- {[...Array(3)].map((_, i) => (
-
- 💧
-
- ))}
-
+ <>
+
+
+
+
+ 😢
+
+
+ 💔
+
+
+ {[...Array(3)].map((_, i) => (
+
+ 💧
+
+ ))}
+
+ >
)}
- >
- )}
-
-
-
-
-
-
- );
- }}
- />
+
+
+
+
+
+
+ );
+ }}
+ />
+
-
+
Continue
- {!form.watch("attendance") && (
-
- )}
- {!form.watch("attendance") && (
-
- Please select your attendance to continue
+ {!hasAcknowledged && !form.watch("attendance")
+ ? "Please acknowledge rules and select attendance"
+ : !hasAcknowledged
+ ? "Please acknowledge the house rules to continue"
+ : "Please select your attendance to continue"}
)}
)}
- {/* STEP 2: Profile Information */}
- {step === "profile" && (
+
+ {/* STEP 2: Details (only if attending) */}
+ {step === "details" && form.watch("attendance") === "yes" && (
- Your Profile
+ Party Details
- Help us connect you with other guests
+ Tell us more about your celebration preferences
@@ -1406,13 +1256,13 @@ export function EnhancedRsvpForm() {
render={({ field }) => (
Related To *
- {
field.onChange(value);
// Reset relationship when relationshipTo changes
form.setValue("relationship", "");
form.setValue("relationshipCustom", "");
- }}
+ }}
value={field.value}
>
@@ -1436,53 +1286,53 @@ export function EnhancedRsvpForm() {
render={({ field }) => {
const relationshipTo = form.watch("relationshipTo");
const showCustomInput = field.value === "Custom" || (!field.value && form.watch("relationshipCustom"));
-
+
// Get relationship options based on relationshipTo
const getRelationshipOptions = () => {
if (!relationshipTo) return [];
-
+
const options: { label: string; value: string; group?: string }[] = [];
-
+
// Family relationships
if (relationshipTo === "bride" || relationshipTo === "groom") {
const family = RELATIONSHIP_OPTIONS[relationshipTo];
-
+
// Grandparents
family.grandparents.forEach(rel => {
options.push({ label: rel, value: rel, group: "Grandparents" });
});
-
+
// Parents
family.parents.forEach(rel => {
options.push({ label: rel, value: rel, group: "Parents & Parental Figures" });
});
-
+
// Siblings
family.siblings.forEach(rel => {
options.push({ label: rel, value: rel, group: "Siblings" });
});
-
+
// Extended Family - Aunts & Uncles (separate, not combined)
family.extended.auntsUncles.forEach(rel => {
options.push({ label: rel, value: rel, group: "Aunts & Uncles" });
});
-
+
// Cousins
family.extended.cousins.forEach(rel => {
options.push({ label: rel, value: rel, group: "Cousins" });
});
-
+
// Younger Generation
family.extended.younger.forEach(rel => {
options.push({ label: rel, value: rel, group: "Younger Generation" });
});
-
+
// In-Laws
family.inLaws.forEach(rel => {
options.push({ label: rel, value: rel, group: "In-Laws" });
});
}
-
+
// Friends (available for both)
RELATIONSHIP_OPTIONS.friends.core.forEach(rel => {
options.push({ label: rel, value: rel, group: "Core Friends" });
@@ -1493,23 +1343,23 @@ export function EnhancedRsvpForm() {
RELATIONSHIP_OPTIONS.friends.relationshipBased.forEach(rel => {
options.push({ label: rel, value: rel, group: "Relationship-Based Friends" });
});
-
+
// Professional
RELATIONSHIP_OPTIONS.professional.forEach(rel => {
options.push({ label: rel, value: rel, group: "Professional" });
});
-
+
// Community
RELATIONSHIP_OPTIONS.community.forEach(rel => {
options.push({ label: rel, value: rel, group: "Community & Social Networks" });
});
-
+
// Custom option
options.push({ label: "Other (Enter custom relationship)", value: "Custom", group: "Other" });
-
+
return options;
};
-
+
const options = getRelationshipOptions();
const groupedOptions = options.reduce((acc, opt) => {
const group = opt.group || "Other";
@@ -1517,11 +1367,11 @@ export function EnhancedRsvpForm() {
acc[group].push(opt);
return acc;
}, {} as Record);
-
+
return (
Relationship *
- {
field.onChange(value);
if (value !== "Custom") {
@@ -1533,14 +1383,14 @@ export function EnhancedRsvpForm() {
customInput?.focus();
}, 100);
}
- }}
+ }}
value={field.value}
disabled={!relationshipTo}
>
-
@@ -1551,9 +1401,9 @@ export function EnhancedRsvpForm() {
{group}
{opts.map((opt) => (
-
{opt.label}
@@ -1564,7 +1414,7 @@ export function EnhancedRsvpForm() {
-
+
{/* Custom relationship input */}
{showCustomInput && (
- {/* Contact Information */}
-
-
- {/* Social Media */}
-
-
-
-
-
-
- Back
-
-
- Continue
- {!isStepValid() && (
-
- )}
-
-
- {!isStepValid() && (
-
-
-
-
-
-
Please complete the following:
-
- {getStepValidationErrors().map((error, index) => (
- {error}
- ))}
-
-
-
-
-
- )}
-
- )}
-
- {/* STEP 3: Details (only if attending) */}
- {step === "details" && form.watch("attendance") === "yes" && (
-
-
-
- Party Details
-
-
- Tell us more about your celebration preferences
-
-
-
-
Number of Guests
- {
- const value = e.target.value;
- const parsed = value ? parseInt(value, 10) : 1;
- field.onChange(isNaN(parsed) ? 1 : Math.max(1, Math.min(parsed, guest.maxPax || 10)));
- }}
- className="wedding-input"
- />
+
+
+
+
+
+
+ Invite valid for {field.value} {field.value === 1 ? 'Guest' : 'Guests'}
+
+
+ Your invite is specifically for {guest?.name || 'you'} and is not transferable.
+
+
+
-
- Including yourself. Max: {guest.maxPax || 2}
-
)}
@@ -1936,15 +1495,44 @@ export function EnhancedRsvpForm() {
-
- Dietary requirements are mandatory when attending
-
+
+
+ Dietary requirements are mandatory when attending
+
+
+ {
+ if (checked) {
+ field.onChange("None");
+ } else {
+ field.onChange("");
+ }
+ }}
+ className="h-4 w-4 border-wedding-ink/30 data-[state=checked]:border-wedding-moss data-[state=checked]:bg-wedding-moss"
+ />
+
+ None
+
+
+
)}
@@ -1958,174 +1546,46 @@ export function EnhancedRsvpForm() {
Accessibility Needs
-
- We'll do our best to accommodate all guests
-
+
+
+ We'll do our best to accommodate all guests
+
+
+ {
+ if (checked) {
+ field.onChange("None");
+ } else {
+ field.onChange("");
+ }
+ }}
+ className="h-4 w-4 border-wedding-ink/30 data-[state=checked]:border-wedding-moss data-[state=checked]:bg-wedding-moss"
+ />
+
+ None
+
+
+
)}
/>
- {/* Social Opt-In for Who's Who Directory */}
-
-
(
-
-
-
-
-
-
-
- Include me in "Who's Who"
-
-
- Allow other guests to see your profile in the guest directory.
- This helps everyone connect and get to know each other before the big day!
-
-
-
-
-
- )}
- />
- {/* Show bio and avatar fields when opted in */}
-
- {form.watch("socialOptIn") && (
-
- {/* Avatar Upload */}
- (
-
- Profile Photo
-
- Upload a photo to appear in your profile (optional)
-
-
-
socialAvatarInputRef.current?.click()}
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- className="group relative size-24 rounded-full overflow-hidden border-4 border-white/70 shadow-xl cursor-pointer bg-gradient-to-br from-wedding-sage/30 to-wedding-moss/20 transition-all hover:shadow-2xl focus:outline-none focus:ring-2 focus:ring-wedding-evergreen/50"
- aria-label="Upload profile photo"
- >
- {socialAvatarPreview ? (
-
- ) : (
-
-
-
- Add Photo
-
-
- )}
-
-
-
-
-
- socialAvatarInputRef.current?.click()}
- className="font-body w-full sm:w-auto"
- >
-
- {socialAvatarPreview ? "Change Photo" : "Upload Photo"}
-
- {socialAvatarPreview && (
- {
- setSocialAvatarPreview(null);
- form.setValue("socialAvatar", "");
- if (socialAvatarInputRef.current) {
- socialAvatarInputRef.current.value = "";
- }
- }}
- className="w-full sm:w-auto text-xs text-rose-600 hover:text-rose-700 font-body"
- >
- Remove Photo
-
- )}
-
-
-
-
-
- )}
- />
-
- {/* Bio Textarea */}
- (
-
- Your Bio
-
-
-
-
- This will appear in your profile
- 140
- ? "text-amber-600"
- : "text-wedding-moss/60"
- )}>
- {field.value?.length || 0}/160
-
-
-
-
- )}
- />
-
- )}
-
-
@@ -2484,7 +1944,7 @@ export function EnhancedRsvpForm() {
-
+
);
}
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 5cb235a..7e995ca 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -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;
};
},
diff --git a/src/types/index.ts b/src/types/index.ts
index ed5cedb..e7bd93a 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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;