Compare commits

...

10 Commits

Author SHA1 Message Date
Brian Nelson
3e916c9d3b . 2025-07-28 08:19:52 -04:00
Brian Nelson
6ea3b151c3 creavte venue inside create event 2025-07-28 08:10:07 -04:00
Brian Nelson
f17382f8ad Update README.md 2025-07-24 09:45:23 -04:00
Brian Nelson
27590f9509 venues and ui changes 2025-07-24 09:42:57 -04:00
Brian Nelson
049def6886 added data table 2025-07-15 14:37:09 -04:00
Brian Nelson
3a2b20e468 added engagement congratulations to guest book 2025-07-15 09:23:33 -04:00
Brian Nelson
5931d042b5 sort todos by complete and date 2025-07-14 18:06:01 -04:00
Brian Nelson
31ce343566 added file upload and storage 2025-07-11 14:21:51 -04:00
Brian Nelson
14cbbccd3a updated login/signup form to shadcn 2025-07-10 09:39:49 -04:00
Brian Nelson
5143be1a67 to do calendar view 2025-07-07 17:45:33 -04:00
53 changed files with 2127 additions and 178 deletions

View File

@@ -71,6 +71,24 @@ My goal for this project is to be an all-in-one self hosted event planner for ma
- Inline editing with live preview - Inline editing with live preview
- Task list per event with due dates & completion toggle - Task list per event with due dates & completion toggle
#### 7.24.25 Notes and Tasks
**Major Update**
- Added Venues
- Venues are significant locations which can be added to an event.
| Column | Default | Required | Type |
|---|---|---|---|
| Name | null | Yes | String |
| Address | null | Yes | String |
| City/Town | null | Yes | String |
| State | null | Yes | String |
| Postal/Area Code | null | Yes | String |
| Country | 'United States' | Yes | String |
| Phone | null | No | String |
| Email | null | No | String |
- UI changes to Dashboard to make use of Shadcn components
## Getting Started ## Getting Started
This app is fully deployable with Docker or runnable in development with Node. This app is fully deployable with Docker or runnable in development with Node.

View File

@@ -1,55 +1,57 @@
import AddFirstGuestBookEntryClient from '@/components/AddFirstGuestBookEntryClient' import DashboardEvents from '@/components/dashboard/DashboardEvents'
import CreateEventClient from '@/components/CreateEventClient' import DashboardGuestBook from '@/components/dashboard/DashboardGuestBook'
import EventInfoQuickView from '@/components/EventInfoQuickView'
import GuestBookQuickView from '@/components/GuestBookQuickView'
import { queries } from '@/lib/queries' import { queries } from '@/lib/queries'
import Link from 'next/link'
import React from 'react' import React from 'react'
export default async function DashboardPage() { export default async function DashboardPage() {
const events = await queries.fetchEvents(); const events = await queries.fetchQuickViewEvents();
const guestBookData = await queries.fetchGuestBookEntries({ takeOnlyRecent: 5 }); const guestBookData = await queries.fetchGuestBookEntries({ takeOnlyRecent: 5 });
const guestBookEntries = Array.isArray(guestBookData) ? guestBookData : guestBookData.entries; const guestBookEntries = Array.isArray(guestBookData) ? guestBookData : guestBookData.entries;
return ( return (
<div className='grid grid-cols-1 md:grid-cols-7 gap-4'> <>
<div className='md:col-span-5 md:row-span-3 bg-[#00000008] rounded-xl p-4 md:p-6 relative'>
<div> <div className='grid grid-cols-1 md:grid-cols-7 gap-4'>
<div className='w-full flex items-center justify-between'> <DashboardEvents events={events} />
<h2 className='text-lg font-semibold py-4'>Your Events</h2> <DashboardGuestBook guestBookEntries={guestBookEntries} />
<CreateEventClient /> {/* <div className='md:col-span-5 md:row-span-3 bg-[#00000008] rounded-xl p-4 md:p-6 relative'>
<div>
<div className='w-full flex items-center justify-between'>
<h2 className='text-lg font-semibold py-4'>Your Events</h2>
<CreateEventClient />
</div>
{!events.length && <>You don&apos;t have any events yet. Create your first event.</>}
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
{events.map((item) => (
<EventInfoQuickView key={item.id} {...item} />
))}
</div>
</div> </div>
{!events.length && <>You don&apos;t have any events yet. Create your first event.</>} <div className='w-full text-right mt-2'>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'> <Link href={'/events'} className='md:absolute bottom-4 right-4 text-sm text-brand-primary-400 hover:underline'>
{events.map((item) => ( View all
<EventInfoQuickView key={item.id} {...item} /> </Link>
</div>
</div>
<div className='md:row-span-5 md:col-start-6 col-span-2 bg-[#00000008] rounded-xl p-6'>
<div className='py-4 flex justify-between'>
<h2 className='text-lg font-semibold'>Guest Book</h2>
<Link
href={'/guest-book'}
className='hover:cursor-pointer hover:underline text-brand-primary-500'
>
View All
</Link>
</div>
<div className='space-y-2'>
{!guestBookEntries.length && <AddFirstGuestBookEntryClient />}
{guestBookEntries.map(entry => (
<GuestBookQuickView key={entry.id} {...entry} />
))} ))}
</div> </div>
</div> </div> */}
<div className='w-full text-right mt-2'>
<Link href={'/events'} className='md:absolute bottom-4 right-4 text-sm text-brand-primary-400 hover:underline'>
View all
</Link>
</div>
</div> </div>
<div className='md:row-span-5 md:col-start-6 col-span-2 bg-[#00000008] rounded-xl p-6'> </>
<div className='py-4 flex justify-between'>
<h2 className='text-lg font-semibold'>Guest Book</h2>
<Link
href={'/guest-book'}
className='hover:cursor-pointer hover:underline text-brand-primary-500'
>
View All
</Link>
</div>
<div className='space-y-2'>
{!guestBookEntries.length && <AddFirstGuestBookEntryClient />}
{guestBookEntries.map(entry => (
<GuestBookQuickView key={entry.id} {...entry} />
))}
</div>
</div>
</div>
) )
} }

View File

@@ -1,16 +1,28 @@
'use client' 'use client'
import { SessionProvider } from 'next-auth/react' import { SessionProvider } from 'next-auth/react'
import { ReactNode } from 'react' import { ReactNode, useContext } from 'react'
import DashboardNavbar from '@/components/DashboardNavbar'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar' import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import { AppSidebar } from '@/components/app-sidebar' import { AppSidebar } from '@/components/app-sidebar'
import { SiteHeader } from '@/components/site-header' import { SiteHeader } from '@/components/site-header'
import { UserContext } from '@/context/UserContext'
import { redirect } from 'next/navigation'
export default function AuthLayout({ children }: { children: ReactNode }) { export default function AuthLayout({ children }: { children: ReactNode }) {
const { currentUser, loading } = useContext(UserContext)
if (loading) {
return <>Loading...</>
}
if (!currentUser) {
redirect('/login')
}
console.log(currentUser)
return ( return (
<> <main>
<SessionProvider> <SessionProvider>
<SidebarProvider <SidebarProvider
style={ style={
@@ -33,6 +45,6 @@ export default function AuthLayout({ children }: { children: ReactNode }) {
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
</SessionProvider> </SessionProvider>
</> </main>
) )
} }

View File

@@ -0,0 +1,85 @@
'use client'
import React, { useState, DragEvent } from 'react'
export default function UploadTestPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const [message, setMessage] = useState('')
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null
setSelectedFile(file)
setMessage('')
}
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
const file = e.dataTransfer.files?.[0]
if (file) {
setSelectedFile(file)
setMessage('')
}
}
const handleUpload = async () => {
if (!selectedFile) return
const formData = new FormData()
formData.append('file', selectedFile)
setUploading(true)
setMessage('')
try {
const res = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) throw new Error(data.message || 'Upload failed')
setMessage(`✅ File uploaded: ${data.filename || selectedFile.name}`)
setSelectedFile(null)
} catch (err: any) {
setMessage(`❌ Upload failed: ${err.message}`)
} finally {
setUploading(false)
}
}
return (
<div className="max-w-md mx-auto mt-10 p-6 bg-white rounded shadow space-y-4">
<h2 className="text-xl font-semibold">File Upload Test</h2>
{/* Drag and drop area */}
<div
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
className="border-2 border-dashed border-gray-400 rounded p-6 text-center cursor-pointer bg-gray-50 hover:bg-gray-100"
>
{selectedFile ? (
<p>{selectedFile.name}</p>
) : (
<p>Drag & drop a file here or click below to select</p>
)}
</div>
{/* File input */}
<input type="file" onChange={handleFileChange} className="block" />
{/* Upload button */}
<button
onClick={handleUpload}
className="btn btn-primary"
disabled={!selectedFile || uploading}
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
{/* Upload result */}
{message && <p className="text-sm">{message}</p>}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import VenuesTable from '@/components/tables/VenuesTable'
import { queries } from '@/lib/queries'
import React from 'react'
export default async function LocationsPage() {
const venues = await queries.fetchAllLocations()
return (
<div>
<VenuesTable eventLocations={venues} />
</div>
)
}

View File

@@ -1,10 +1,17 @@
import LoginForm from '@/components/LoginForm'; import FormWrapper from '@/components/forms/FormWrapper'
import LoginForm from '@/components/forms/LoginForm'
import React from 'react' import React from 'react'
export default function LoginPage() { export default function LoginPage() {
return ( return (
<div> <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<LoginForm /> <div className="w-full max-w-sm">
<FormWrapper
title='Login to your account'
description='Enter your email below to login to your account'
form={<LoginForm />}
/>
</div>
</div> </div>
) )
} }

View File

@@ -1,5 +1,9 @@
import { verifyInvite } from '@/lib/invite' import { verifyInvite } from '@/lib/invite'
import SignupForm from '@/components/SignupForm' import FormWrapper from '@/components/forms/FormWrapper'
import SignUpForm from '@/components/forms/SignUpForm'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import Link from 'next/link'
import { IconArrowLeft } from '@tabler/icons-react'
interface Props { interface Props {
searchParams: { searchParams: {
@@ -11,13 +15,38 @@ export default async function SignupPage({ searchParams }: Props) {
const invite = searchParams.token ? await verifyInvite(searchParams.token) : null const invite = searchParams.token ? await verifyInvite(searchParams.token) : null
if (!invite || invite.accepted || new Date(invite.expiresAt) < new Date()) { if (!invite || invite.accepted || new Date(invite.expiresAt) < new Date()) {
return <div className="text-center mt-10">Invalid or expired invitation.</div> return (
<div className="flex min-h-svh w-full justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<Card>
<CardHeader className='py-2'>
<CardTitle>
<div className="text-center">Invalid or expired invitation.</div>
</CardTitle>
</CardHeader>
<CardContent>
<p>Reach out to the couple or event planner to get a new invitation link.</p>
<Link href={'/'} className='mt-4 text-brand-primary-400 flex items-center hover:underline'>
<IconArrowLeft />
Back to Homepage
</Link>
</CardContent>
</Card>
</div>
</div>
)
} }
return ( return (
<div className="max-w-md mx-auto mt-10"> <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<h1 className="text-2xl font-bold mb-4">Complete Your Signup</h1> <div className="w-full max-w-sm">
<SignupForm invite={invite} /> <FormWrapper
title='Complete Your Signup'
description='Choose a username to finish signing up'
form={<SignUpForm invite={invite} />}
/>
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'
export async function GET(
req: NextRequest,
{ params }: { params: { eventId: string } }
) {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
}
try {
const event = await prisma.event.findUnique({
where: { id: params.eventId },
include: {
creator: {
select: { id: true, name: true, email: true, role: true }
},
venue: true,
todos: {
orderBy: [
{ complete: 'asc' },
{ dueDate: 'asc' }
]
},
eventGuests: {
include: {
guestBookEntry: true
}
}
}
})
if (!event) {
return NextResponse.json({ message: 'Event not found' }, { status: 404 })
}
return NextResponse.json(event)
} catch (err) {
console.error(err)
return NextResponse.json({ message: 'Error fetching event' }, { status: 500 })
}
}

View File

@@ -1,27 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server'
import { mutations } from '@/lib/mutations'; import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth'
import { authOptions } from '../../auth/[...nextauth]/route'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'
export async function PATCH(req: NextRequest, { params }: { params: { eventId: string } }) { export async function PATCH(req: NextRequest, { params }: { params: { eventId: string } }) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions)
if (!session?.user) { if (!session?.user) {
return new NextResponse('Unauthorized', { status: 401 }); return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
} }
const eventId = params.eventId; const body = await req.json()
const body = await req.json();
try { try {
const updated = await mutations.updateEvent(eventId, { const updated = await prisma.event.update({
name: body.name, where: { id: params.eventId },
date: body.date, data: {
location: body.location, name: body.name,
notes: body.notes, date: body.date ? new Date(body.date) : undefined,
}); venueId: body.venueId || null,
return NextResponse.json(updated); },
} catch (error) { })
console.error('[PATCH EVENT]', error);
return new NextResponse('Failed to update event', { status: 500 }); return NextResponse.json(updated)
} catch (err) {
console.error(err)
return NextResponse.json({ message: 'Error updating event' }, { status: 500 })
} }
} }

View File

@@ -0,0 +1,86 @@
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "../../auth/[...nextauth]/route";
import { canUploadOrViewFiles } from "@/lib/auth/checkRole";
import { v4 as uuidv4 } from 'uuid';
import path from "path";
import { mkdirSync, existsSync, writeFileSync } from 'fs';
import { prisma } from "@/lib/prisma";
const MAX_SIZE_MB = parseInt(process.env.UPLOAD_FILE_SIZE || '10', 10);
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
// Ensure it's relative to project root
const UPLOAD_DIR = path.join(process.cwd(), 'data/uploads');
export const config = {
api: {
bodyParser: false,
},
};
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
const user = session?.user;
if (!user || !canUploadOrViewFiles(user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
const eventId = formData.get("eventId") as string | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
if (file.size > MAX_SIZE_BYTES) {
return NextResponse.json({ error: `File exceeds ${MAX_SIZE_MB}MB` }, { status: 400 });
}
const allowedTypes = ["application/pdf", "image/jpeg", "image/png", "text/plain"];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
}
// Ensure uploads directory exists
if (!existsSync(UPLOAD_DIR)) {
mkdirSync(UPLOAD_DIR, { recursive: true });
}
// Create unique filename
const safeFileName = `${uuidv4()}-${file.name}`;
const fullPath = path.join(UPLOAD_DIR, safeFileName);
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
writeFileSync(fullPath, buffer);
// Save metadata in DB
const saved = await prisma.fileUpload.create({
data: {
filename: file.name,
filepath: fullPath,
filetype: file.type,
filesize: file.size,
uploadedBy: {
connect: { email: user.email! },
},
event: eventId ? {
connect: { id: eventId }
} : undefined,
},
});
return NextResponse.json({
message: "File uploaded successfully",
file: saved,
}, { status: 201 });
} catch (error) {
console.error("File upload error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,20 @@
import { getServerSession } from "next-auth";
import { authOptions } from "../../auth/[...nextauth]/route";
import { NextResponse } from "next/server";
import { queries } from "@/lib/queries";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await queries.fetchCurrentUser(session.user.id)
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
try {
const venue = await prisma.venue.create({
data: {
name: body.name,
address: body.address,
city: body.city,
state: body.state,
postalCode: body.postalCode,
country: body.country,
phone: body.phone || undefined,
email: body.email || undefined
}
})
return NextResponse.json(venue)
} catch (err) {
console.error(err)
return NextResponse.json({ message: 'Error creating venue' }, { status: 500 })
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
const venues = await prisma.venue.findMany()
return NextResponse.json(venues)
} catch (error) {
console.error('Failed to fetch venues:', error)
return new NextResponse('Failed to fetch venues', { status: 500 })
}
}

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import Provider from "@/components/auth/Provider";
import { UserContextProvider } from "@/context/UserContext";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Wedding Planner", title: "Wedding Planner",
@@ -18,8 +20,12 @@ export default async function RootLayout({
<body <body
className="bg-brand-background text-brand-text" className="bg-brand-background text-brand-text"
> >
{children} <Provider>
<Toaster /> <UserContextProvider>
{children}
</UserContextProvider>
<Toaster />
</Provider>
</body> </body>
</html> </html>
); );

View File

@@ -13,6 +13,7 @@ export default function EditGuestBookEntryModal({ isOpen, onClose, initialData,
email?: string email?: string
phone?: string phone?: string
address?: string address?: string
congratulated?: boolean | null
side?: string side?: string
notes?: string notes?: string
} }
@@ -109,6 +110,21 @@ export default function EditGuestBookEntryModal({ isOpen, onClose, initialData,
value={formData.address || ''} value={formData.address || ''}
onChange={handleChange} onChange={handleChange}
/> />
<div className="flex items-center gap-2">
<input
type="checkbox"
name="congratulated"
checked={!!formData.congratulated}
onChange={(e) =>
setFormData((prev) => ({
...prev,
congratulated: e.target.checked,
}))
}
className="checkbox"
/>
<label htmlFor="congratulated">Congratulated</label>
</div>
<input <input
className="input input-bordered w-full" className="input input-bordered w-full"
type="text" type="text"

View File

@@ -1,15 +1,22 @@
import Link from 'next/link' import Link from 'next/link'
import React from 'react' import React from 'react'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
export default function EventInfoQuickView(props: EventProps) { export default function EventInfoQuickView(props: QucikEventProps) {
return ( return (
<Link href={`/events/${props.id}`} > <Link href={`/events/${props.id}`}>
<div className='hover:cursor-pointer rounded-lg p-2 bg-brand-primary-900 hover:bg-brand-primary-800 transition-colors duration-200'> <Card className='bg-brand-primary-900 hover:bg-brand-primary-800 transition-colors duration-200'>
<h3 className='text-md font-semibold'>{props.name}</h3> <CardHeader>
<p>Date: {props.date ? props.date.toDateString() : 'null'}</p> <CardTitle>{props.name}</CardTitle>
<p>Location: {props.location ? props.location : 'null'}</p> </CardHeader>
<p className='text-xs mt-2'>Created By: {props.creator.username}</p> <CardContent>
</div> <p>Date: {props.date ? props.date.toDateString() : 'null'}</p>
<p>Location: {props.venue ? props.venue.name : 'null'}</p>
</CardContent>
<CardFooter>
<p className='text-xs mt-2'>Created By: {props.creator.username}</p>
</CardFooter>
</Card>
</Link> </Link>
) )
} }

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import React, { useState } from 'react' import React, { useState } from 'react'
import EditGuestBookEntryModal from './EditGuestBookEntryModal' import EditGuestBookEntryModal from './EditGuestBookEntryModal'
// import GuestBookTable from './tables/GuestBookTable'
interface GuestBookEntry { interface GuestBookEntry {
id: string id: string
@@ -10,6 +11,7 @@ interface GuestBookEntry {
email?: string | null email?: string | null
phone?: string | null phone?: string | null
address?: string | null address?: string | null
congratulated?: boolean | null
notes?: string | null notes?: string | null
} }
@@ -27,6 +29,7 @@ export default function GuestBookList({ entries, view }: { entries: GuestBookEnt
email?: string email?: string
phone?: string phone?: string
address?: string address?: string
congratulated?: boolean | null
side?: string side?: string
notes?: string notes?: string
}) { }) {
@@ -40,6 +43,7 @@ export default function GuestBookList({ entries, view }: { entries: GuestBookEnt
email: updated.email, email: updated.email,
phone: updated.phone, phone: updated.phone,
address: updated.address, address: updated.address,
congratulated: updated.congratulated,
side: updated.side, side: updated.side,
notes: updated.notes, notes: updated.notes,
}), }),
@@ -60,34 +64,39 @@ export default function GuestBookList({ entries, view }: { entries: GuestBookEnt
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
{view === 'TABLE' ? ( {view === 'TABLE' ? (
<div className='overflow-hidden rounded-xl'> <>
<table className='table-auto w-full mb-16 p-4'> <div className='overflow-hidden rounded-xl'>
<thead className='bg-brand-primary text-brand-background border border-brand-primary'> <table className='table-auto w-full mb-16 p-4'>
<tr className='text-left'> <thead className='bg-brand-primary text-brand-background border border-brand-primary'>
<th className='px-4 py-2'>Name</th> <tr className='text-left'>
<th className='px-4 py-2'>Email</th> <th className='px-4 py-2'>Name</th>
<th className='px-4 py-2'>Phone</th> <th className='px-4 py-2'>Email</th>
<th className='px-4 py-2'>Address</th> <th className='px-4 py-2'>Phone</th>
<th className='px-4 py-2'>Notes</th> <th className='px-4 py-2'>Address</th>
</tr> <th className='px-4 py-2'>Congratulated Engagement</th>
</thead> <th className='px-4 py-2'>Notes</th>
<tbody className=''>
{entries.map(entry => (
<tr
key={entry.id}
className='odd:bg-brand-primary-900 even:bg-brand-primary-950 hover:cursor-pointer hover:bg-brand-primary-700 hover:text-brand-background transition-colors duration-50'
onClick={() => setEditingEntry(entry)}
>
<td className='border border-brand-primary px-4 py-2'>{entry.fName + ' ' + entry.lName} <span className='text-sm'>(Side: {entry.side})</span></td>
<td className='border border-brand-primary px-4 py-2'>{entry.email || 'N/A'}</td>
<td className='border border-brand-primary px-4 py-2'>{entry.phone || 'N/A'}</td>
<td className='border border-brand-primary px-4 py-2'>{entry.address || 'N/A'}</td>
<td className='border border-brand-primary px-4 py-2'>{entry.notes || 'N/A'}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody className=''>
</table> {entries.map(entry => (
</div> <tr
key={entry.id}
className='odd:bg-brand-primary-900 even:bg-brand-primary-950 hover:cursor-pointer hover:bg-brand-primary-700 hover:text-brand-background transition-colors duration-50'
onClick={() => setEditingEntry(entry)}
>
<td className='border border-brand-primary px-4 py-2'>{entry.fName + ' ' + entry.lName} <span className='text-sm'>(Side: {entry.side})</span></td>
<td className='border border-brand-primary px-4 py-2'>{entry.email || 'N/A'}</td>
<td className='border border-brand-primary px-4 py-2'>{entry.phone || 'N/A'}</td>
<td className='border border-brand-primary px-4 py-2'>{entry.address || 'N/A'}</td>
<td className='border border-brand-primary px-4 py-2'>{entry.congratulated == true ? 'Yes' : "No"}</td>
<td className='border border-brand-primary px-4 py-2'>{entry.notes || 'N/A'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* <GuestBookTable guestBookEntries={entries} /> */}
</>
) : ( ) : (
<div className='space-y-4 mx-auto'> <div className='space-y-4 mx-auto'>
<div className='grid grid-cols-3 gap-4 w-full'> <div className='grid grid-cols-3 gap-4 w-full'>
@@ -122,6 +131,7 @@ export default function GuestBookList({ entries, view }: { entries: GuestBookEnt
email: editingEntry.email || '', email: editingEntry.email || '',
phone: editingEntry.phone || '', phone: editingEntry.phone || '',
address: editingEntry.address || '', address: editingEntry.address || '',
congratulated: editingEntry.congratulated,
side: editingEntry.side, side: editingEntry.side,
notes: editingEntry.notes || '', notes: editingEntry.notes || '',
}} }}

View File

@@ -2,13 +2,17 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Card, CardContent } from './ui/card' import { Card, CardContent } from './ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
import EventTaskCalendar from './events/EventTaskCalendar'
interface Todo { interface Todo {
id: string id: string
name: string name: string
complete: boolean complete: boolean
dueDate?: string | null dueDate?: string | null
notes?: string | null notes?: string | null
eventId: string
createdAt: string
updatedAt: string
} }
interface Props { interface Props {
@@ -195,8 +199,10 @@ export default function ToDoList({ eventId, initialTodos, onUpdate }: Props) {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value='calendar'> <TabsContent value='calendar'>
<div className="grid grid-cols-7 gap-1 text-xs"> <div className=''>
<EventTaskCalendar
todos={todos}
/>
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -2,14 +2,10 @@
import * as React from "react" import * as React from "react"
import { import {
IconCamera, IconBuildingArch,
IconChartBar,
IconDashboard, IconDashboard,
IconDatabase, IconDatabase,
IconFileAi,
IconFileDescription,
IconFileWord, IconFileWord,
IconFolder,
IconHelp, IconHelp,
IconInnerShadowTop, IconInnerShadowTop,
IconListDetails, IconListDetails,
@@ -19,9 +15,7 @@ import {
IconUsers, IconUsers,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { NavDocuments } from "@/components/nav-documents"
import { NavMain } from "@/components/nav-main" import { NavMain } from "@/components/nav-main"
import { NavSecondary } from "@/components/nav-secondary"
import { NavUser } from "@/components/nav-user" import { NavUser } from "@/components/nav-user"
import { import {
Sidebar, Sidebar,
@@ -32,8 +26,8 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { useSession } from "next-auth/react"
import Link from "next/link" import Link from "next/link"
import { UserContext } from "@/context/UserContext"
const data = { const data = {
navMain: [ navMain: [
@@ -52,6 +46,11 @@ const data = {
url: "/guest-book", url: "/guest-book",
icon: IconUsers, icon: IconUsers,
}, },
{
title: "Locations",
url: "/venues",
icon: IconBuildingArch,
},
], ],
// navClouds: [ // navClouds: [
// { // {
@@ -138,8 +137,7 @@ const data = {
} }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const session = useSession() const { currentUser } = React.useContext(UserContext)
const user = session.data?.user
return ( return (
<Sidebar collapsible="offcanvas" {...props}> <Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader> <SidebarHeader>
@@ -162,9 +160,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{/* <NavDocuments items={data.documents} /> {/* <NavDocuments items={data.documents} />
<NavSecondary items={data.navSecondary} className="mt-auto" /> */} <NavSecondary items={data.navSecondary} className="mt-auto" /> */}
</SidebarContent> </SidebarContent>
{session && ( {currentUser && (
<SidebarFooter> <SidebarFooter>
<NavUser user={user} /> <NavUser user={currentUser} />
</SidebarFooter> </SidebarFooter>
)} )}

View File

@@ -0,0 +1,11 @@
'use client'
import { SessionProvider } from 'next-auth/react'
import React from 'react'
export default function Provider({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
{children}
</SessionProvider>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import React, { useState } from 'react'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '../ui/card'
import { Button } from '../ui/button'
import Link from 'next/link'
import EventInfoQuickView from '../EventInfoQuickView'
import DialogWrapper from '../dialogs/DialogWrapper'
import CreateEventForm from '../forms/CreateEventForm'
interface EventsProps {
events: {
id: string
name: string
date?: Date | null
creator: {
id: string,
username: string
},
venue?: {
name: string
} | null
}[]
}
export default function DashboardEvents({events}: EventsProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false)
return (
<>
<Card className='md:col-span-5 pb-3'>
<CardHeader>
<div className='flex justify-between items-center'>
<CardTitle>
Your Events
</CardTitle>
<Button
className='bg-brand-primary-600 hover:bg-brand-primary-400'
onClick={() => setIsDialogOpen(true)}
>
Create Event
</Button>
</div>
</CardHeader>
<CardContent>
<div className='grid md:grid-cols-3 gap-3'>
{events.map((item) => (
<EventInfoQuickView key={item.id} {...item} />
))}
</div>
</CardContent>
<CardFooter>
<div className='text-right w-full text-sm'>
<Link href={'/events'}>View all</Link>
</div>
</CardFooter>
</Card>
<DialogWrapper
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
title="Create Event"
description="Add new event"
form={
<CreateEventForm onSuccess={async () => {
// await refreshEventData(event.id)
setIsDialogOpen(false)
}}
/>
}
/>
</>
)
}

View File

@@ -0,0 +1,41 @@
import React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'
import Link from 'next/link'
import AddFirstGuestBookEntryClient from '../AddFirstGuestBookEntryClient'
import GuestBookQuickView from '../GuestBookQuickView'
interface GuestBookEntryProps {
guestBookEntries: {
id: string
fName: string
lName: string
email?: string | null
phone?: string | null
address?: string | null
notes?: string | null
side: string
congratulated?: boolean | null
createdAt: Date
}[]
}
export default function DashboardGuestBook(guestBookEntries: GuestBookEntryProps) {
return (
<Card className='md:col-start-6 col-span-2 row-span-2'>
<CardHeader>
<div className='flex justify-between items-center'>
<CardTitle>
Guest Book
</CardTitle>
<Link href={'/guest-book'}>View All</Link>
</div>
</CardHeader>
<CardContent className='space-y-2'>
{!guestBookEntries.guestBookEntries.length && <AddFirstGuestBookEntryClient />}
{guestBookEntries.guestBookEntries.map(entry => (
<GuestBookQuickView key={entry.id} {...entry} />
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,30 @@
'use client'
import React from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'
export default function DialogWrapper({
title,
description,
form,
open,
onOpenChange,
}: {
title: string,
description?: string,
form: React.ReactNode,
open: boolean,
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()} className="max-h-[90vh] overflow-y-auto w-full max-w-3xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4">{form}</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,8 @@
'use client'
import React from 'react'
export default function EditEventDialog() {
return (
<div>EditEventDialog</div>
)
}

View File

@@ -13,7 +13,7 @@ interface Props {
export default function EventDashboard({ event }: Props) { export default function EventDashboard({ event }: Props) {
const [todos, setTodos] = useState(event.todos) const [todos, setTodos] = useState(event.todos)
async function refreshTodos() { async function refreshTodos() {
try { try {
const data = await fetchEventTodos(event.id) const data = await fetchEventTodos(event.id)
setTodos(data) setTodos(data)
@@ -27,7 +27,6 @@ export default function EventDashboard({ event }: Props) {
<EventInfo event={event} /> <EventInfo event={event} />
<div className='lg:col-span-2 space-y-4'> <div className='lg:col-span-2 space-y-4'>
<EventRsvpTracking eventGuests={event.eventGuests} /> <EventRsvpTracking eventGuests={event.eventGuests} />
{/* <EventToDoList tasks={event.todos} /> */}
<ToDoList <ToDoList
eventId={event.id} eventId={event.id}
initialTodos={todos} initialTodos={todos}

View File

@@ -4,6 +4,8 @@ import { Card, CardContent } from '../ui/card'
import { getDaysUntilEvent } from '@/lib/helper/getDaysUntilEvent' import { getDaysUntilEvent } from '@/lib/helper/getDaysUntilEvent'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import EventNotesEditor from '../EventNotesEditor' import EventNotesEditor from '../EventNotesEditor'
import DialogWrapper from '../dialogs/DialogWrapper'
import EditEventForm from '../forms/EditEventForm'
interface EventProps { interface EventProps {
event: EventData event: EventData
@@ -11,6 +13,7 @@ interface EventProps {
export default function EventInfo({ event }: EventProps) { export default function EventInfo({ event }: EventProps) {
const [daysLeft, setDaysLeft] = useState<number | null>(null) const [daysLeft, setDaysLeft] = useState<number | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (event.date) { if (event.date) {
@@ -19,20 +22,38 @@ export default function EventInfo({ event }: EventProps) {
} }
}, [event.date]) }, [event.date])
async function refreshEventData(eventId: string) {
try {
const res = await fetch(`/api/events/${eventId}/fetch`)
if (!res.ok) throw new Error('Failed to fetch event data')
const data = await res.json()
return data
} catch (err) {
console.error('Failed to refresh event data:', err)
return null
}
}
return ( return (
<div className='lg:col-span-1 space-y-4'> <div className='lg:col-span-1 space-y-4'>
<Card className='py-0'> <Card className='py-0'>
<CardContent className='p-4'> <CardContent className='p-4'>
<h2 className='text-xl font-semibold'>Event Info</h2> <h2 className='text-xl font-semibold'>Event Info</h2>
<p className='text-sm mt-2'>Nmae: {event.name}</p> <p className='text-sm mt-2'>Name: {event.name}</p>
<p className='text-sm'>Date: {event.date ? event.date.toDateString() : 'Upcoming'}</p> <p className='text-sm'>Date: {event.date ? event.date.toDateString() : 'Upcoming'}</p>
<p className='text-sm'>Location: {event.location ? event.location : 'No location yet'}</p> <p className='text-sm'>Venue: {event.venue ? event.venue.name : 'No location yet'}</p>
{daysLeft !== null && ( {daysLeft !== null && (
<p className='text-sm mt-2 font-medium text-brand-primary-400'> <p className='text-sm mt-2 font-medium text-brand-primary-400'>
{daysLeft} days until this event! {daysLeft} days until this event!
</p> </p>
)} )}
<Button className="mt-4 w-full bg-brand-primary-600 hover:bg-brand-primary-400">Edit Event</Button> <Button
className="mt-4 w-full bg-brand-primary-600 hover:bg-brand-primary-400"
onClick={() => setIsDialogOpen(true)}
>
Edit Event
</Button>
</CardContent> </CardContent>
</Card> </Card>
<Card className='py-0'> <Card className='py-0'>
@@ -45,6 +66,20 @@ export default function EventInfo({ event }: EventProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
<DialogWrapper
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
title="Edit Event"
description="Update the event details"
form={
<EditEventForm event={event} onSuccess={async () => {
await refreshEventData(event.id)
setIsDialogOpen(false)
}}
/>
}
/>
</div> </div>
) )
} }

View File

@@ -0,0 +1,35 @@
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid' // a plugin!
import type { EventInput } from '@fullcalendar/core'
interface EventTodo {
id: string
name: string
complete: boolean
dueDate?: string | null
notes?: string | null
eventId: string
createdAt: string
updatedAt: string
}
export default function EventTaskCalendar({ todos }: { todos: EventTodo[] }) {
const calendarEvents: EventInput[] = todos
.filter(todo => !!todo.dueDate)
.map(todo => ({
id: todo.id,
title: todo.name,
start: todo.dueDate as string,
backgroundColor: todo.complete ? '#9ae6b4' : '#fbd38d',
borderColor: todo.complete ? '#38a169' : '#dd6b20',
allDay: true,
}))
return (
<FullCalendar
plugins={[ dayGridPlugin ]}
initialView="dayGridMonth"
height={650}
events={calendarEvents}
/>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import React, { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Label } from '../ui/label'
import { Input } from '../ui/input'
import { Button } from '../ui/button'
import CreateVenueForm from './CreateVenueForm'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'
interface CreateEventFormProps {
onSuccess?: () => void
}
export default function CreateEventForm({ onSuccess }: CreateEventFormProps) {
const [formData, setFormData] = useState({
name: '',
date: '',
venueId: ''
})
const [venues, setVenues] = useState<{ id: string; name: string }[]>([])
// const [showVenueForm, setShowVenueForm] = useState(false)
const [venueDialogOpen, setVenueDialogOpen] = useState(false)
useEffect(() => {
async function fetchVenues() {
const res = await fetch('/api/venues/fetch')
const data = await res.json()
setVenues(data)
}
fetchVenues()
}, [])
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!formData.name || !formData.date) {
toast.error('Event Name and Date are required')
return
}
try {
const res = await fetch('/api/events/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
date: formData.date,
venueId: formData.venueId || null
})
})
if (!res.ok) throw new Error('Failed to create event')
toast.success('Event created!')
if (onSuccess) onSuccess()
} catch (err) {
toast.error('Something went wrong')
console.error(err)
}
}
return (
<>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Event Details */}
<div className="space-y-4">
<div>
<Label htmlFor="name">Event Name *</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div>
<Label htmlFor="date">Event Date *</Label>
<Input
id="date"
name="date"
type="date"
value={formData.date}
onChange={handleChange}
required
/>
</div>
</div>
{/* Venue Selection */}
<div className="space-y-2">
<Label htmlFor="venueId">Choose a Venue</Label>
<select
name="venueId"
id="venueId"
value={formData.venueId}
onChange={handleChange}
className="input input-bordered w-full"
>
<option value=""> Select a venue </option>
{venues.map(v => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
<Button type="button" variant="outline" onClick={() => setVenueDialogOpen(true)}>
Or create new venue
</Button>
</div>
{/* Submit */}
<Button type="submit" className="w-full">
Create Event
</Button>
</form>
<Dialog open={venueDialogOpen} onOpenChange={setVenueDialogOpen}>
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Create New Venue</DialogTitle>
<DialogDescription>Fill in venue details</DialogDescription>
</DialogHeader>
<CreateVenueForm
onSuccess={async (newVenueId) => {
// 1. Close the dialog
setVenueDialogOpen(false)
// 2. Refresh venues list
const res = await fetch('/api/venues/fetch')
const updated = await res.json()
setVenues(updated)
// 3. Update formData with new venue
setFormData(prev => ({
...prev,
venueId: newVenueId || ''
}))
}}
/>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import React, { useState } from 'react'
import { Input } from '../ui/input'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { toast } from 'sonner'
interface CreateVenueFormProps {
onSuccess?: (newVenueId?: string) => void
}
export default function CreateVenueForm({ onSuccess }: CreateVenueFormProps) {
const [formData, setFormData] = useState({
name: '',
address: '',
city: '',
state: '',
postalCode: '',
country: 'United States',
phone: '',
email: ''
})
const [loading, setLoading] = useState(false)
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!formData.name || !formData.address || !formData.city || !formData.state || !formData.postalCode) {
toast.error('Please fill in all required fields')
return
}
setLoading(true)
try {
const res = await fetch('/api/venues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
if (!res.ok) throw new Error('Failed to create venue')
const data = await res.json()
const newVenueId = data?.id
toast.success('Venue created!')
setFormData({
name: '',
address: '',
city: '',
state: '',
postalCode: '',
country: 'United States',
phone: '',
email: ''
})
if (onSuccess) onSuccess(newVenueId)
} catch (err) {
console.error(err)
toast.error('Something went wrong')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{[
{ label: 'Name', name: 'name', required: true },
{ label: 'Address', name: 'address', required: true },
{ label: 'City', name: 'city', required: true },
{ label: 'State', name: 'state', required: true },
{ label: 'Postal Code', name: 'postalCode', required: true },
{ label: 'Country', name: 'country', required: false },
{ label: 'Phone', name: 'phone', required: false },
{ label: 'Email', name: 'email', required: false }
].map(field => (
<div key={field.name} className="space-y-1">
<Label htmlFor={field.name}>
{field.label}{field.required && ' *'}
</Label>
<Input
id={field.name}
name={field.name}
value={formData[field.name as keyof typeof formData]}
onChange={handleChange}
required={field.required}
/>
</div>
))}
{/* <div className="space-y-1">
<Label htmlFor='name'>
Name *
</Label>
<Input
id='name'
name='name'
value={formData[field.name as keyof typeof formData]}
onChange={handleChange}
required={field.required}
/>
</div> */}
<Button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Venue'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import React, { useState, useEffect } from 'react'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Button } from '../ui/button'
import { toast } from 'sonner'
interface EditEventFormProps {
event: EventData
onSuccess?: () => void
}
export default function EditEventForm({ event, onSuccess }: EditEventFormProps) {
const [formData, setFormData] = useState({
name: event.name,
date: event.date?.toISOString().substring(0, 10) || '',
venueId: event.venue?.id || ''
})
const [venues, setVenues] = useState<{ id: string; name: string }[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
async function fetchVenues() {
const res = await fetch('/api/venues/fetch')
const data = await res.json()
setVenues(data)
}
fetchVenues()
}, [])
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const res = await fetch(`/api/events/${event.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
if (!res.ok) throw new Error('Failed to update event')
toast.success('Event updated!')
if (onSuccess) onSuccess()
} catch (err) {
console.error(err)
toast.error('Something went wrong')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Event Name</Label>
<Input id="name" name="name" value={formData.name} onChange={handleChange} required />
</div>
<div>
<Label htmlFor="date">Event Date</Label>
<Input id="date" name="date" type="date" value={formData.date} onChange={handleChange} />
</div>
<div>
<Label htmlFor="venueId">Venue</Label>
<select
id="venueId"
name="venueId"
className="input input-bordered w-full"
value={formData.venueId}
onChange={handleChange}
>
<option value="">No venue</option>
{venues.map(v => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,26 @@
import React from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
export default function FormWrapper({
title,
description,
form
}: {
title: string,
description?: string,
form: React.ReactNode
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && (
<CardDescription>Enter your email below to login</CardDescription>
)}
</CardHeader>
<CardContent>
{form}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import React, { useState } from 'react'
import { Label } from '../ui/label'
import { Input } from '../ui/input'
import Link from 'next/link'
import { Button } from '../ui/button'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
export default function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('')
const [error, setError] = useState('');
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const result = await signIn('credentials', {
redirect: false,
email,
password,
})
console.log('[CLIENT] signIn result:', result)
if (result?.error) {
setError(result.error)
} else {
router.push('/dashboard')
}
}
return (
<form onSubmit={handleSubmit}>
<div className='flex flex-col gap-6'>
<div className='grid gap-3'>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
type='email'
placeholder='m@example.com'
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className='grid gap-3'>
<div className='flex items-center'>
<Label htmlFor='password'>Password</Label>
<Link
href={'#'}
className='ml-auto inline-block text-sm underline-offset-4 hover:underline'
>
Forgot your password?
</Link>
</div>
<Input
id='password'
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div className='flex flex-col gap-3'>
<Button
type="submit"
className="w-full bg-brand-primary-600 hover:bg-brand-primary-400"
>
Login
</Button>
{error && <p className="text-red-500">{error}</p>}
</div>
</div>
</form>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useRouter } from 'next/navigation'
import React, { useState } from 'react'
import { Label } from '../ui/label'
import { Input } from '../ui/input'
import { Button } from '../ui/button'
interface Props {
invite: {
token: string
email: string
role: 'COUPLE' | 'PLANNER' | 'GUEST'
}
}
export default function SignUpForm({ invite }: Props) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const res = await fetch('/api/signup/from-invite', {
method: 'POST',
body: JSON.stringify({ token: invite.token, username, password }),
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
router.push('/login')
} else {
const { message } = await res.json()
setError(message || 'Signup failed')
}
}
return (
<form onSubmit={handleSubmit}>
<p className="text-sm text-gray-600">
Invited as <strong>{invite.email}</strong> ({invite.role})
</p>
<div className='flex flex-col gap-6'>
<div className='grid gap-3'>
<Label htmlFor='username'>Username</Label>
<Input
id='username'
type='text'
placeholder='Choose a username'
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className='grid gap-3'>
<Label htmlFor='password'>Password</Label>
<Input
id='password'
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div className='flex flex-col gap-3'>
<Button
type="submit"
className="w-full bg-brand-primary-600 hover:bg-brand-primary-400"
>
Sign Up
</Button>
{error && <p className="text-red-500">{error}</p>}
</div>
</div>
</form>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
SortingState,
getSortedRowModel
} from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '../ui/table'
import { useState } from 'react'
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
className?: string
}
export function DataTable<TData, TValue>({
columns,
data,
className
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel()
})
return (
<div className={`rounded-md border ${className || ''}`}>
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} >
{headerGroup.headers.map(header => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map(row => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
No results
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from './DataTable'
interface GuestBookEntryRow {
id: string
fName: string
lName: string
side: string
address: string
notes?: string | null
phone?: string | null
email?: string | null
congratulated?: boolean | null
}
interface Props {
guestBookEntries: GuestBookEntryRow[]
}
const columns: ColumnDef<GuestBookEntryRow>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => row.original.fName + " " + row.original.lName
},
{
accessorKey: 'side',
header: 'Side'
},
{
accessorKey: 'email',
header: 'Email',
cell: ({ row }) => row.original.email || '—'
},
{
accessorKey: 'phone',
header: 'Phone',
cell: ({ row }) => row.original.phone || '—'
},
{
accessorKey: 'address',
header: 'Address',
cell: ({ row }) => row.original.address || '—'
},
{
accessorKey: 'congratulated',
header: 'Congratulated Engagement',
cell: ({ row }) => row.original.congratulated == true ? "Yes" : 'No'
},
{
accessorKey: 'notes',
header: 'Notes',
cell: ({ row }) => row.original.notes || '—'
},
]
export default function GuestBookTable({ guestBookEntries }: Props) {
return (
<div className="mt-4">
<DataTable columns={columns} data={guestBookEntries} />
</div>
)
}

View File

@@ -0,0 +1,99 @@
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from './DataTable'
import { Button } from '../ui/button'
import DialogWrapper from '../dialogs/DialogWrapper'
import CreateVenueForm from '../forms/CreateVenueForm'
import { useState } from 'react'
import { fetchVenuesClient } from '@/lib/helper/fetchVenues'
interface LocationRow {
id: string
name: string
address: string
city: string
state: string
postalCode: string
country: string
phone?: string | null
email?: string | null
}
interface Props {
eventLocations: LocationRow[]
}
const columns: ColumnDef<LocationRow>[] = [
{
accessorKey: 'name',
header: 'Name'
},
{
accessorKey: 'address',
header: 'Address'
},
{
accessorKey: 'city',
header: 'City'
},
{
accessorKey: 'state',
header: 'State'
},
{
accessorKey: 'postalCode',
header: 'Postal Code'
},
{
accessorKey: 'country',
header: 'Country'
},
{
accessorKey: 'phone',
header: 'Phone',
cell: ({ row }) => row.original.phone || '—'
},
{
accessorKey: 'email',
header: 'Email',
cell: ({ row }) => row.original.email || '—'
}
]
export default function VenuesTable({ eventLocations }: Props) {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [venues, setVenues] = useState<LocationRow[]>(eventLocations)
async function refreshVenues() {
try {
const updated = await fetchVenuesClient()
setVenues(updated)
} catch (err) {
console.error('Failed to refresh venues:', err)
}
}
return (
<div className="space-y-4">
<Button
variant={'outline'}
onClick={() => setIsDialogOpen(true)}
>
Create Venue
</Button>
<DataTable columns={columns} data={venues} />
<DialogWrapper
title="Create a New Venue"
description="Enter the Venue information below"
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
form={<CreateVenueForm onSuccess={async () => {
await refreshVenues()
setIsDialogOpen(false)
}}
/>}
/>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:cursor-pointer",
{ {
variants: { variants: {
variant: { variant: {

47
context/UserContext.tsx Normal file
View File

@@ -0,0 +1,47 @@
'use client'
import { useSession } from 'next-auth/react'
import React, { createContext, useEffect, useState } from 'react'
type UserContextType = {
currentUser: User | null
loading: boolean
}
export const UserContext = createContext<UserContextType>({
currentUser: null,
loading: true
});
export const UserContextProvider = ({ children }: { children: React.ReactNode }) => {
const { data: session, status } = useSession();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchUser() {
try {
const res = await fetch('api/users/current-user')
if (!res.ok) throw new Error('User not found')
const data: User = await res.json()
setCurrentUser(data)
} catch (err) {
console.error('Failed to fetch current user:', err)
} finally {
setLoading(false)
}
}
if (status === 'authenticated' && session?.user?.id) {
fetchUser()
} else if (status === 'unauthenticated') {
setLoading(false)
}
}, [session?.user.id, status])
return (
<UserContext.Provider value={{ currentUser, loading }}>
{children}
</UserContext.Provider>
)
}

View File

@@ -11,6 +11,10 @@ services:
volumes: volumes:
- ./data/postgres:/var/lib/postgresql/data - ./data/postgres:/var/lib/postgresql/data
# app:
# volumes:
# - ./data/uploads:/app/data/uploads
volumes: volumes:
pgdata: pgdata:

169
drift_fix.sql Normal file
View File

@@ -0,0 +1,169 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('COUPLE', 'PLANNER', 'GUEST');
-- CreateEnum
CREATE TYPE "RsvpStatus" AS ENUM ('YES', 'NO', 'PENDING');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT,
"name" TEXT,
"username" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'GUEST',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"date" TIMESTAMP(3),
"locationid" TEXT,
"creatorId" TEXT NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL DEFAULT 'United States',
"phone" TEXT,
"email" TEXT,
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Guest" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"rsvp" "RsvpStatus" NOT NULL DEFAULT 'PENDING',
CONSTRAINT "Guest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InviteToken" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "Role" NOT NULL,
"token" TEXT NOT NULL,
"eventId" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InviteToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GuestBookEntry" (
"id" TEXT NOT NULL,
"fName" TEXT NOT NULL,
"lName" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"address" TEXT,
"notes" TEXT,
"side" TEXT NOT NULL,
"congratulated" BOOLEAN,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GuestBookEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventGuest" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"guestBookEntryId" TEXT NOT NULL,
"rsvp" "RsvpStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EventGuest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventTodo" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"complete" BOOLEAN NOT NULL DEFAULT false,
"dueDate" TIMESTAMP(3),
"notes" TEXT,
"eventId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventTodo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileUpload" (
"id" TEXT NOT NULL,
"filepath" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"filetype" TEXT NOT NULL,
"filesize" INTEGER NOT NULL,
"uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"uploadedById" TEXT NOT NULL,
"eventId" TEXT,
CONSTRAINT "FileUpload_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "InviteToken_email_key" ON "InviteToken"("email");
-- CreateIndex
CREATE UNIQUE INDEX "InviteToken_token_key" ON "InviteToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "EventGuest_eventId_guestBookEntryId_key" ON "EventGuest"("eventId", "guestBookEntryId");
-- CreateIndex
CREATE UNIQUE INDEX "FileUpload_filename_uploadedById_key" ON "FileUpload"("filename", "uploadedById");
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_locationid_fkey" FOREIGN KEY ("locationid") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Guest" ADD CONSTRAINT "Guest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventGuest" ADD CONSTRAINT "EventGuest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventGuest" ADD CONSTRAINT "EventGuest_guestBookEntryId_fkey" FOREIGN KEY ("guestBookEntryId") REFERENCES "GuestBookEntry"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventTodo" ADD CONSTRAINT "EventTodo_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileUpload" ADD CONSTRAINT "FileUpload_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileUpload" ADD CONSTRAINT "FileUpload_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;

5
lib/auth/checkRole.ts Normal file
View File

@@ -0,0 +1,5 @@
import { Role } from '@prisma/client'
export function canUploadOrViewFiles(role: Role | undefined | null): boolean {
return role === 'COUPLE' || role === 'PLANNER'
}

View File

@@ -0,0 +1,5 @@
export async function fetchVenuesClient() {
const res = await fetch('/api/venues/fetch', { cache: 'no-store' }) // ensure no stale cache
if (!res.ok) throw new Error('Failed to fetch venues')
return res.json()
}

View File

@@ -192,5 +192,29 @@ export const mutations = {
}); });
}, },
async createEventLocation(data: {
name: string,
address: string,
city: string,
state: string,
postalCode: string,
country: string,
phone?: string,
email?: string,
}) {
return await prisma.location.create({
data: {
name: data.name,
address: data.address,
city: data.city,
state: data.state,
postalCode: data.postalCode,
country: data.country || 'United States',
phone: data.phone,
email: data.email
}
})
}
}; };

View File

@@ -9,13 +9,38 @@ export const queries = {
id: true, id: true,
username: true username: true
} }
} },
} venue: true
},
}) })
return allEvents; return allEvents;
}, },
async fetchQuickViewEvents() {
const events = await prisma.event.findMany({
take: 3,
select: {
id: true,
name: true,
date: true,
creator: {
select: {
id: true,
username: true,
}
},
venue: {
select: {
name: true,
},
},
}
})
return events
},
async fetchEventGuests(eventId: string) { async fetchEventGuests(eventId: string) {
return await prisma.eventGuest.findMany({ return await prisma.eventGuest.findMany({
where: { eventId }, where: { eventId },
@@ -72,8 +97,12 @@ export const queries = {
}, },
}, },
todos: { todos: {
orderBy: { dueDate: 'asc' }, orderBy: [
{ complete: 'asc' },
{ dueDate: 'asc' },
],
}, },
venue: true
} }
}) })
return event return event
@@ -92,34 +121,46 @@ export const queries = {
}) { }) {
// ⏱ Quick recent entries (e.g., homepage) // ⏱ Quick recent entries (e.g., homepage)
if (takeOnlyRecent) { if (takeOnlyRecent) {
const entries = await prisma.guestBookEntry.findMany({ const entries = await prisma.guestBookEntry.findMany({
take: takeOnlyRecent, take: takeOnlyRecent,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
return entries return entries
} }
// 📄 Paginated GuestBook view // 📄 Paginated GuestBook view
const skip = ((page ?? 1) - 1) * pageSize const skip = ((page ?? 1) - 1) * pageSize
const [entries, totalCount] = await Promise.all([ const [entries, totalCount] = await Promise.all([
prisma.guestBookEntry.findMany({ prisma.guestBookEntry.findMany({
skip, skip,
take: pageSize, take: pageSize,
orderBy: newestFirst orderBy: newestFirst
? { createdAt: 'desc' } ? { createdAt: 'desc' }
: [{ lName: 'asc' }, { fName: 'asc' }], : [{ lName: 'asc' }, { fName: 'asc' }],
}), }),
prisma.guestBookEntry.count(), prisma.guestBookEntry.count(),
]) ])
const totalPages = Math.ceil(totalCount / pageSize) const totalPages = Math.ceil(totalCount / pageSize)
return { return {
entries, entries,
totalPages, totalPages,
currentPage: page ?? 1, currentPage: page ?? 1,
} }
}, },
async fetchCurrentUser(id: string | null) {
if (!id) return
return await prisma.user.findUnique({
where: { id },
})
},
async fetchAllLocations() {
return await prisma.venue.findMany()
},
} }

68
package-lock.json generated
View File

@@ -12,8 +12,12 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/react": "^6.1.18",
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.11.1",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
@@ -48,6 +52,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.25.74" "zod": "^3.25.74"
}, },
@@ -411,6 +416,45 @@
"version": "0.2.9", "version": "0.2.9",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fullcalendar/core": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.18.tgz",
"integrity": "sha512-cD7XtZIZZ87Cg2+itnpsONCsZ89VIfLLDZ22pQX4IQVWlpYUB3bcCf878DhWkqyEen6dhi5ePtBoqYgm5K+0fQ==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/core/node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.18.tgz",
"integrity": "sha512-s452Zle1SdMEzZDw+pDczm8m3JLIZzS9ANMThXTnqeqJewW1gqNFYas18aHypJSgF9Fh9rDJjTSUw04BpXB/Mg==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.18"
}
},
"node_modules/@fullcalendar/react": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.18.tgz",
"integrity": "sha512-Jwvb+T+/1yGZKe+UYXn0id022HJm0Fq2X/PGFvVh/QRYAI/6xPMRvJrwercBkToxf6LjqYXrDO+/NhRN6IDlmg==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.18",
"react": "^16.7.0 || ^17 || ^18 || ^19",
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
}
},
"node_modules/@headlessui/react": { "node_modules/@headlessui/react": {
"version": "2.2.4", "version": "2.2.4",
"license": "MIT", "license": "MIT",
@@ -758,10 +802,11 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.10.1", "version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.11.1.tgz",
"integrity": "sha512-5CLFh8QP6KxRm83pJ84jaVCeSVPQr8k0L2SEtOJHwdkS57/VQDcI/wQpGmdyOZi+D9gdNabdo8tj1Uk+w+upsQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=18.18" "node": ">=18.18"
}, },
@@ -6464,6 +6509,15 @@
} }
} }
}, },
"node_modules/next-auth/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/next-themes": { "node_modules/next-themes": {
"version": "0.4.6", "version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -8199,10 +8253,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/esm/bin/uuid"
} }
}, },
"node_modules/vaul": { "node_modules/vaul": {

View File

@@ -15,8 +15,12 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/react": "^6.1.18",
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.11.1",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
@@ -51,6 +55,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.25.74" "zod": "^3.25.74"
}, },

BIN
prisma/migrations/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,27 @@
/*
Warnings:
- You are about to drop the column `location` on the `Event` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "location",
ADD COLUMN "locationid" TEXT;
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL DEFAULT 'United States',
"phone" TEXT,
"email" TEXT,
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_locationid_fkey" FOREIGN KEY ("locationid") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "FileUpload" (
"id" TEXT NOT NULL,
"filepath" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"filetype" TEXT NOT NULL,
"filesize" INTEGER NOT NULL,
"uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"uploadedById" TEXT NOT NULL,
"eventId" TEXT,
CONSTRAINT "FileUpload_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FileUpload_filename_uploadedById_key" ON "FileUpload"("filename", "uploadedById");
-- AddForeignKey
ALTER TABLE "FileUpload" ADD CONSTRAINT "FileUpload_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileUpload" ADD CONSTRAINT "FileUpload_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "GuestBookEntry" ADD COLUMN "congratulated" BOOLEAN;

View File

@@ -0,0 +1,34 @@
/*
Warnings:
- You are about to drop the column `locationid` on the `Event` table. All the data in the column will be lost.
- You are about to drop the `Location` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Event" DROP CONSTRAINT "Event_locationid_fkey";
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "locationid",
ADD COLUMN "venueId" TEXT;
-- DropTable
DROP TABLE "Location";
-- CreateTable
CREATE TABLE "Venue" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL DEFAULT 'United States',
"phone" TEXT,
"email" TEXT,
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -16,6 +16,8 @@ model User {
role Role @default(GUEST) role Role @default(GUEST)
events Event[] @relation("EventCreator") events Event[] @relation("EventCreator")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
FileUpload FileUpload[]
} }
enum Role { enum Role {
@@ -28,23 +30,42 @@ model Event {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
date DateTime? date DateTime?
location String? venue Venue? @relation(fields: [venueId], references: [id])
venueId String?
creator User @relation("EventCreator", fields: [creatorId], references: [id]) creator User @relation("EventCreator", fields: [creatorId], references: [id])
creatorId String creatorId String
guests Guest[] guests Guest[]
eventGuests EventGuest[] eventGuests EventGuest[]
notes String? notes String?
todos EventTodo[] todos EventTodo[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
FileUpload FileUpload[]
}
model Venue {
id String @id @default(cuid())
name String
address String
city String
state String
postalCode String
country String @default("United States")
phone String?
email String?
createdAt DateTime @default(now())
Event Event[]
} }
model Guest { model Guest {
id String @id @default(cuid()) id String @id @default(cuid())
event Event @relation(fields: [eventId], references: [id]) event Event @relation(fields: [eventId], references: [id])
eventId String eventId String
name String name String
email String? email String?
rsvp RsvpStatus @default(PENDING) rsvp RsvpStatus @default(PENDING)
// attended RsvpStatus @default(PENDING)
} }
enum RsvpStatus { enum RsvpStatus {
@@ -65,16 +86,17 @@ model InviteToken {
} }
model GuestBookEntry { model GuestBookEntry {
id String @id @default(cuid()) id String @id @default(cuid())
fName String fName String
lName String lName String
email String? email String?
phone String? phone String?
address String? address String?
notes String? notes String?
side String // e.g., "Brian", "Janice", etc. side String
eventGuests EventGuest[] congratulated Boolean?
createdAt DateTime @default(now()) eventGuests EventGuest[]
createdAt DateTime @default(now())
} }
model EventGuest { model EventGuest {
@@ -104,3 +126,18 @@ model EventTodo {
// category String? // category String?
// assignedTo String? // could link to User in future // assignedTo String? // could link to User in future
} }
model FileUpload {
id String @id @default(cuid())
filepath String
filename String
filetype String
filesize Int //in bytes
uploadedAt DateTime @default(now())
uploadedBy User @relation(fields: [uploadedById], references: [id])
uploadedById String
event Event? @relation(fields: [eventId], references: [id])
eventId String?
@@unique([filename, uploadedById])
}

42
types.d.ts vendored
View File

@@ -15,13 +15,26 @@ interface EventProps {
id: string id: string
name: string name: string
date?: Date | null date?: Date | null
location?: string | null location?: EventLocation | null
creator: { id: string; username: string; } creator: { id: string; username: string; }
createdAt: Date; date: Date | null; createdAt: Date; date: Date | null;
creatorId: string; creatorId: string;
key: string; key: string;
} }
interface QucikEventProps {
id: string
name: string
date?: Date | null
creator: {
id: string
username: string
},
venue?: {
name: string
} | null
}
interface Creator { interface Creator {
id: string id: string
email: string email: string
@@ -34,17 +47,17 @@ interface Todo {
name: string name: string
complete: boolean complete: boolean
dueDate?: string | null dueDate?: string | null
createdAt: string
updatedAt?: string
dueDate?: string | null
notes?: string | null notes?: string | null
eventId: string
createdAt: string
updatedAt: string
} }
interface EventData { interface EventData {
id: string id: string
name: string name: string
date: Date | null date: Date | null
location: string | null venue: Venue | null
creatorId: string creatorId: string
createdAt: string createdAt: string
creator: Creator creator: Creator
@@ -63,4 +76,23 @@ interface EventGuest {
lName: string lName: string
email?: string | null email?: string | null
} }
}
type User = {
id: string
email: string
name?: string
username: string
role: 'COUPLE' | 'PLANNER' | 'GUEST'
}
interface Venue {
id: string
name: string
address: string
city: string
state: string
country: string
phone: string | null
email: string | null
} }