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 ( -
-
Please login first.
-
- ); - - 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 ( +
+
+
+ + + +

Gallery

+
+
+ +
+
+
+ +
+

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 ( +
+
+
+ + + +

Music

+
+
+ +
+
+
+ +
+

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."} -

- -
-
- - {/* Profile Card */} - - - My Profile - - - -

- manage your contact info, dietary needs, and profile photo. -

- -
-
- - {/* Who's Who Card */} - - - Who's Who - - - -

- Share how you know the couple and find friends. -

- -
-
- - {/* Guestbook Card */} - - - Guestbook - - - -

- {guest.guestbookMessage ? "Your message is saved." : "Write a note for the couple."} -

- -
-
-
-
- ); + 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 ( +
+
+
+ + + +

Our Story

+
+
+ +
+
+ +
+

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 ( +
+
+
+ + + +

Tribute Wall

+
+
+ +
+ +
+
+ ); +} 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() {
- {/* STEP 0: House Rules */} - {step === "rules" && ( - -
-

- House Rules -

-

- Please review and acknowledge our wedding guidelines before proceeding. -

-
- -
- {HOUSE_RULES.map((rule, i) => { - const Icon = rule.icon; - return ( - -
-
- -
-
-
-

- {rule.title} -

-

- {rule.desc} -

-
-
- ); - })} -
- - {/* Acknowledgment - Glass Panel Soft */} -
-
- setHasAcknowledged(checked === true)} - className="border-2 border-wedding-ink/30 data-[state=checked]:border-wedding-evergreen data-[state=checked]:bg-wedding-evergreen mt-0.5" - /> - -
-
- -
- -
-
- )} - - {/* STEP 1: Attendance */} + {/* STEP 1: Attendance & Rules */} {step === "attendance" && ( -
-

- Will you attend? +
+

+ House Rules & Attendance

- We'd love to celebrate with you on our special day! + Please review our guidelines and let us know if you'll be joining us!

- { - const handleAttendanceChange = (value: string) => { - field.onChange(value); - // Emojis will display automatically based on field.value - }; - - return ( - - - +

Wedding Guidelines

+
+ {HOUSE_RULES.map((rule, i) => { + const Icon = rule.icon; + return ( + - - - - +
+
+ +
+
+
+
+ {rule.title} +
+

+ {rule.desc} +

+
+
+ ); + })} +
- - -

+
+ +
+ +
+

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 */} +
+ +
+
+

+ Accept +

+
+ {field.value === "yes" && ( + <> + + + + + 🎉 + + + 🎊 + + + )} + + + + + + - -
-
- -
- ); - }} - /> + + + + + + + ); + }} + /> +
-
+
- {!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); 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 */} -
-

Contact Information (Optional)

- - ( - -
- - - - ( - - - - - - Show in directory - - - )} - /> -
- -
- )} - /> - - ( - -
- - - - ( - - - - - - Show in directory - - - )} - /> -
- -
- )} - /> - - {/* Email Notifications Opt-in */} - {form.watch("email") && (form.watch("email")?.trim()?.length ?? 0) > 0 && ( - ( - - - - - - Send me RSVP confirmation email with wedding pass - - - )} - /> - )} -
- - {/* Social Media */} -
-

Social Media (Optional)

- - ( - -
- - - - ( - - - - - - Show in directory - - - )} - /> -
- -
- )} - /> - - ( - -
- - - - ( - - - - - - Show in directory - - - )} - /> -
- -
- )} - /> - - ( - -
- - - - ( - - - - - - Show in directory - - - )} - /> -
- -
- )} - /> -
-
- -
- - -
- {!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" + /> + +
+
)} @@ -1958,174 +1546,46 @@ export function EnhancedRsvpForm() { Accessibility Needs