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
- 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
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 CreateEventClient from '@/components/CreateEventClient'
import EventInfoQuickView from '@/components/EventInfoQuickView'
import GuestBookQuickView from '@/components/GuestBookQuickView'
import DashboardEvents from '@/components/dashboard/DashboardEvents'
import DashboardGuestBook from '@/components/dashboard/DashboardGuestBook'
import { queries } from '@/lib/queries'
import Link from 'next/link'
import React from 'react'
export default async function DashboardPage() {
const events = await queries.fetchEvents();
const events = await queries.fetchQuickViewEvents();
const guestBookData = await queries.fetchGuestBookEntries({ takeOnlyRecent: 5 });
const guestBookEntries = Array.isArray(guestBookData) ? guestBookData : guestBookData.entries;
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='w-full flex items-center justify-between'>
<h2 className='text-lg font-semibold py-4'>Your Events</h2>
<CreateEventClient />
<>
<div className='grid grid-cols-1 md:grid-cols-7 gap-4'>
<DashboardEvents events={events} />
<DashboardGuestBook guestBookEntries={guestBookEntries} />
{/* <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>
{!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 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 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 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'
import { SessionProvider } from 'next-auth/react'
import { ReactNode } from 'react'
import DashboardNavbar from '@/components/DashboardNavbar'
import { ReactNode, useContext } from 'react'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import { AppSidebar } from '@/components/app-sidebar'
import { SiteHeader } from '@/components/site-header'
import { UserContext } from '@/context/UserContext'
import { redirect } from 'next/navigation'
export default function AuthLayout({ children }: { children: ReactNode }) {
const { currentUser, loading } = useContext(UserContext)
if (loading) {
return <>Loading...</>
}
if (!currentUser) {
redirect('/login')
}
console.log(currentUser)
return (
<>
<main>
<SessionProvider>
<SidebarProvider
style={
@@ -33,6 +45,6 @@ export default function AuthLayout({ children }: { children: ReactNode }) {
</SidebarInset>
</SidebarProvider>
</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'
export default function LoginPage() {
return (
<div>
<LoginForm />
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<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>
)
}

View File

@@ -1,5 +1,9 @@
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 {
searchParams: {
@@ -11,13 +15,38 @@ export default async function SignupPage({ searchParams }: Props) {
const invite = searchParams.token ? await verifyInvite(searchParams.token) : null
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 (
<div className="max-w-md mx-auto mt-10">
<h1 className="text-2xl font-bold mb-4">Complete Your Signup</h1>
<SignupForm invite={invite} />
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<FormWrapper
title='Complete Your Signup'
description='Choose a username to finish signing up'
form={<SignUpForm invite={invite} />}
/>
</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 { mutations } from '@/lib/mutations';
import { getServerSession } from 'next-auth';
import { authOptions } from '../../auth/[...nextauth]/route';
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 PATCH(req: NextRequest, { params }: { params: { eventId: string } }) {
const session = await getServerSession(authOptions);
const session = await getServerSession(authOptions)
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 {
const updated = await mutations.updateEvent(eventId, {
name: body.name,
date: body.date,
location: body.location,
notes: body.notes,
});
return NextResponse.json(updated);
} catch (error) {
console.error('[PATCH EVENT]', error);
return new NextResponse('Failed to update event', { status: 500 });
const updated = await prisma.event.update({
where: { id: params.eventId },
data: {
name: body.name,
date: body.date ? new Date(body.date) : undefined,
venueId: body.venueId || null,
},
})
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 "./globals.css";
import { Toaster } from "@/components/ui/sonner";
import Provider from "@/components/auth/Provider";
import { UserContextProvider } from "@/context/UserContext";
export const metadata: Metadata = {
title: "Wedding Planner",
@@ -18,8 +20,12 @@ export default async function RootLayout({
<body
className="bg-brand-background text-brand-text"
>
{children}
<Toaster />
<Provider>
<UserContextProvider>
{children}
</UserContextProvider>
<Toaster />
</Provider>
</body>
</html>
);

View File

@@ -13,6 +13,7 @@ export default function EditGuestBookEntryModal({ isOpen, onClose, initialData,
email?: string
phone?: string
address?: string
congratulated?: boolean | null
side?: string
notes?: string
}
@@ -109,6 +110,21 @@ export default function EditGuestBookEntryModal({ isOpen, onClose, initialData,
value={formData.address || ''}
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
className="input input-bordered w-full"
type="text"

View File

@@ -1,15 +1,22 @@
import Link from 'next/link'
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 (
<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'>
<h3 className='text-md font-semibold'>{props.name}</h3>
<p>Date: {props.date ? props.date.toDateString() : 'null'}</p>
<p>Location: {props.location ? props.location : 'null'}</p>
<p className='text-xs mt-2'>Created By: {props.creator.username}</p>
</div>
<Link href={`/events/${props.id}`}>
<Card className='bg-brand-primary-900 hover:bg-brand-primary-800 transition-colors duration-200'>
<CardHeader>
<CardTitle>{props.name}</CardTitle>
</CardHeader>
<CardContent>
<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>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import React, { useState } from 'react'
import EditGuestBookEntryModal from './EditGuestBookEntryModal'
// import GuestBookTable from './tables/GuestBookTable'
interface GuestBookEntry {
id: string
@@ -10,6 +11,7 @@ interface GuestBookEntry {
email?: string | null
phone?: string | null
address?: string | null
congratulated?: boolean | null
notes?: string | null
}
@@ -27,6 +29,7 @@ export default function GuestBookList({ entries, view }: { entries: GuestBookEnt
email?: string
phone?: string
address?: string
congratulated?: boolean | null
side?: string
notes?: string
}) {
@@ -40,6 +43,7 @@ export default function GuestBookList({ entries, view }: { entries: GuestBookEnt
email: updated.email,
phone: updated.phone,
address: updated.address,
congratulated: updated.congratulated,
side: updated.side,
notes: updated.notes,
}),
@@ -60,34 +64,39 @@ export default function GuestBookList({ entries, view }: { entries: GuestBookEnt
return (
<div className='space-y-4'>
{view === 'TABLE' ? (
<div className='overflow-hidden rounded-xl'>
<table className='table-auto w-full mb-16 p-4'>
<thead className='bg-brand-primary text-brand-background border border-brand-primary'>
<tr className='text-left'>
<th className='px-4 py-2'>Name</th>
<th className='px-4 py-2'>Email</th>
<th className='px-4 py-2'>Phone</th>
<th className='px-4 py-2'>Address</th>
<th className='px-4 py-2'>Notes</th>
</tr>
</thead>
<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>
<>
<div className='overflow-hidden rounded-xl'>
<table className='table-auto w-full mb-16 p-4'>
<thead className='bg-brand-primary text-brand-background border border-brand-primary'>
<tr className='text-left'>
<th className='px-4 py-2'>Name</th>
<th className='px-4 py-2'>Email</th>
<th className='px-4 py-2'>Phone</th>
<th className='px-4 py-2'>Address</th>
<th className='px-4 py-2'>Congratulated Engagement</th>
<th className='px-4 py-2'>Notes</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<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.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='grid grid-cols-3 gap-4 w-full'>
@@ -122,6 +131,7 @@ export default function GuestBookList({ entries, view }: { entries: GuestBookEnt
email: editingEntry.email || '',
phone: editingEntry.phone || '',
address: editingEntry.address || '',
congratulated: editingEntry.congratulated,
side: editingEntry.side,
notes: editingEntry.notes || '',
}}

View File

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

View File

@@ -2,14 +2,10 @@
import * as React from "react"
import {
IconCamera,
IconChartBar,
IconBuildingArch,
IconDashboard,
IconDatabase,
IconFileAi,
IconFileDescription,
IconFileWord,
IconFolder,
IconHelp,
IconInnerShadowTop,
IconListDetails,
@@ -19,9 +15,7 @@ import {
IconUsers,
} from "@tabler/icons-react"
import { NavDocuments } from "@/components/nav-documents"
import { NavMain } from "@/components/nav-main"
import { NavSecondary } from "@/components/nav-secondary"
import { NavUser } from "@/components/nav-user"
import {
Sidebar,
@@ -32,8 +26,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { useSession } from "next-auth/react"
import Link from "next/link"
import { UserContext } from "@/context/UserContext"
const data = {
navMain: [
@@ -52,6 +46,11 @@ const data = {
url: "/guest-book",
icon: IconUsers,
},
{
title: "Locations",
url: "/venues",
icon: IconBuildingArch,
},
],
// navClouds: [
// {
@@ -138,8 +137,7 @@ const data = {
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const session = useSession()
const user = session.data?.user
const { currentUser } = React.useContext(UserContext)
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
@@ -162,9 +160,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{/* <NavDocuments items={data.documents} />
<NavSecondary items={data.navSecondary} className="mt-auto" /> */}
</SidebarContent>
{session && (
{currentUser && (
<SidebarFooter>
<NavUser user={user} />
<NavUser user={currentUser} />
</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) {
const [todos, setTodos] = useState(event.todos)
async function refreshTodos() {
async function refreshTodos() {
try {
const data = await fetchEventTodos(event.id)
setTodos(data)
@@ -27,7 +27,6 @@ export default function EventDashboard({ event }: Props) {
<EventInfo event={event} />
<div className='lg:col-span-2 space-y-4'>
<EventRsvpTracking eventGuests={event.eventGuests} />
{/* <EventToDoList tasks={event.todos} /> */}
<ToDoList
eventId={event.id}
initialTodos={todos}

View File

@@ -4,6 +4,8 @@ import { Card, CardContent } from '../ui/card'
import { getDaysUntilEvent } from '@/lib/helper/getDaysUntilEvent'
import { Button } from '../ui/button'
import EventNotesEditor from '../EventNotesEditor'
import DialogWrapper from '../dialogs/DialogWrapper'
import EditEventForm from '../forms/EditEventForm'
interface EventProps {
event: EventData
@@ -11,6 +13,7 @@ interface EventProps {
export default function EventInfo({ event }: EventProps) {
const [daysLeft, setDaysLeft] = useState<number | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
useEffect(() => {
if (event.date) {
@@ -19,20 +22,38 @@ export default function EventInfo({ event }: EventProps) {
}
}, [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 (
<div className='lg:col-span-1 space-y-4'>
<Card className='py-0'>
<CardContent className='p-4'>
<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'>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 && (
<p className='text-sm mt-2 font-medium text-brand-primary-400'>
{daysLeft} days until this event!
</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>
</Card>
<Card className='py-0'>
@@ -45,6 +66,20 @@ export default function EventInfo({ event }: EventProps) {
/>
</CardContent>
</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>
)
}

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"
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: {
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:
- ./data/postgres:/var/lib/postgresql/data
# app:
# volumes:
# - ./data/uploads:/app/data/uploads
volumes:
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,
username: true
}
}
}
},
venue: true
},
})
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) {
return await prisma.eventGuest.findMany({
where: { eventId },
@@ -72,8 +97,12 @@ export const queries = {
},
},
todos: {
orderBy: { dueDate: 'asc' },
orderBy: [
{ complete: 'asc' },
{ dueDate: 'asc' },
],
},
venue: true
}
})
return event
@@ -92,34 +121,46 @@ export const queries = {
}) {
// ⏱ Quick recent entries (e.g., homepage)
if (takeOnlyRecent) {
const entries = await prisma.guestBookEntry.findMany({
take: takeOnlyRecent,
orderBy: { createdAt: 'desc' },
})
return entries
const entries = await prisma.guestBookEntry.findMany({
take: takeOnlyRecent,
orderBy: { createdAt: 'desc' },
})
return entries
}
// 📄 Paginated GuestBook view
const skip = ((page ?? 1) - 1) * pageSize
const [entries, totalCount] = await Promise.all([
prisma.guestBookEntry.findMany({
skip,
take: pageSize,
orderBy: newestFirst
? { createdAt: 'desc' }
: [{ lName: 'asc' }, { fName: 'asc' }],
}),
prisma.guestBookEntry.count(),
prisma.guestBookEntry.findMany({
skip,
take: pageSize,
orderBy: newestFirst
? { createdAt: 'desc' }
: [{ lName: 'asc' }, { fName: 'asc' }],
}),
prisma.guestBookEntry.count(),
])
const totalPages = Math.ceil(totalCount / pageSize)
return {
entries,
totalPages,
currentPage: page ?? 1,
entries,
totalPages,
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/sortable": "^10.0.0",
"@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",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.11.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
@@ -48,6 +52,7 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.25.74"
},
@@ -411,6 +416,45 @@
"version": "0.2.9",
"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": {
"version": "2.2.4",
"license": "MIT",
@@ -758,10 +802,11 @@
}
},
"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,
"license": "Apache-2.0",
"peer": true,
"engines": {
"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": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -8199,10 +8253,16 @@
"license": "MIT"
},
"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",
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vaul": {

View File

@@ -15,8 +15,12 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@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",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.11.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
@@ -51,6 +55,7 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"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)
events Event[] @relation("EventCreator")
createdAt DateTime @default(now())
FileUpload FileUpload[]
}
enum Role {
@@ -28,23 +30,42 @@ model Event {
id String @id @default(cuid())
name String
date DateTime?
location String?
venue Venue? @relation(fields: [venueId], references: [id])
venueId String?
creator User @relation("EventCreator", fields: [creatorId], references: [id])
creatorId String
guests Guest[]
eventGuests EventGuest[]
notes String?
todos EventTodo[]
todos EventTodo[]
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 {
id String @id @default(cuid())
event Event @relation(fields: [eventId], references: [id])
eventId String
name String
email String?
rsvp RsvpStatus @default(PENDING)
id String @id @default(cuid())
event Event @relation(fields: [eventId], references: [id])
eventId String
name String
email String?
rsvp RsvpStatus @default(PENDING)
// attended RsvpStatus @default(PENDING)
}
enum RsvpStatus {
@@ -65,16 +86,17 @@ model InviteToken {
}
model GuestBookEntry {
id String @id @default(cuid())
fName String
lName String
email String?
phone String?
address String?
notes String?
side String // e.g., "Brian", "Janice", etc.
eventGuests EventGuest[]
createdAt DateTime @default(now())
id String @id @default(cuid())
fName String
lName String
email String?
phone String?
address String?
notes String?
side String
congratulated Boolean?
eventGuests EventGuest[]
createdAt DateTime @default(now())
}
model EventGuest {
@@ -104,3 +126,18 @@ model EventTodo {
// category String?
// 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
name: string
date?: Date | null
location?: string | null
location?: EventLocation | null
creator: { id: string; username: string; }
createdAt: Date; date: Date | null;
creatorId: string;
key: string;
}
interface QucikEventProps {
id: string
name: string
date?: Date | null
creator: {
id: string
username: string
},
venue?: {
name: string
} | null
}
interface Creator {
id: string
email: string
@@ -34,17 +47,17 @@ interface Todo {
name: string
complete: boolean
dueDate?: string | null
createdAt: string
updatedAt?: string
dueDate?: string | null
notes?: string | null
eventId: string
createdAt: string
updatedAt: string
}
interface EventData {
id: string
name: string
date: Date | null
location: string | null
venue: Venue | null
creatorId: string
createdAt: string
creator: Creator
@@ -63,4 +76,23 @@ interface EventGuest {
lName: string
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
}