Compare commits

...

4 Commits

Author SHA1 Message Date
a5775930aa Add photo ZIP downloads, HEIC/HEIF uploads, thank-you notes, ICS export, and theme color overrides
- Photos: guests and hosts can download all event photos as a ZIP;
  HEIC/HEIF uploads are now accepted, with a WebP thumbnail generated
  for gallery display while the original file is preserved for
  download; uploader name now always shows as a caption
- Hosts can send a thank-you note (email/SMS) to guests who RSVP'd yes
- Guests can add the event to Google Calendar or download an .ics file,
  and see a live countdown to the event
- Hosts can override a theme's primary/accent colors per event
- New textures for a few themes' card backgrounds
2026-07-19 18:23:05 -04:00
d9f980b640 Tone down the Retro Arcade theme
- Swap Press Start 2P (a pixel-block font meant for short logo-sized
  text) for VT323, a retro terminal font that stays legible across
  full paragraphs of body copy
- Loosen the pixel-grid background from a dense 40px wall-to-wall
  neon grid to a sparser 120px tile with faint gridlines and a few
  low-opacity flecks, in line with the other themes' patterns
2026-07-12 12:20:59 -04:00
a6d1ade53c Add manual RSVP entry, status pills, and delete confirmation
- Hosts can record an RSVP for a guest who let them know directly
  (call, text, in person) instead of using the invite form
- RSVP status now renders as a colored pill instead of plain text
- Removing an RSVP now asks for confirmation, since it's a hard delete
- Fixes a bug in the edit-RSVP form: useActionState's dispatch isn't
  awaitable, so the previous await-then-close logic could close the
  row and swallow a validation error before the update actually
  finished; now watches isPending settling instead
2026-07-12 12:18:12 -04:00
cb99fec940 Let hosts edit or remove individual RSVPs
Hosts can now fix a guest's headcount, correct their name/note, change
their status, or remove an RSVP entirely from the guest list page -
covers cases where a guest messages the host directly instead of
resubmitting the form.
2026-07-12 12:14:25 -04:00
47 changed files with 3252 additions and 207 deletions

View File

@ -6,10 +6,11 @@ services:
# Baked into the client bundle at build time - see Dockerfile.
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}
ports:
- "3100:3000"
- "3101:3100"
environment:
PORT: 3100
DATABASE_URL: ${DATABASE_URL:-postgresql://einvite:einvite@postgres:5432/einvite?schema=public}
APP_URL: ${APP_URL:-http://localhost:3100}
APP_URL: ${APP_URL:-http://localhost:3101}
SESSION_SECRET: ${SESSION_SECRET:-change-me-to-a-long-random-string}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
SMTP_HOST: ${SMTP_HOST:-mailpit}
@ -32,7 +33,7 @@ services:
mailpit:
condition: service_started
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test: ["CMD", "node", "-e", "fetch('http://localhost:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 20s
@ -63,3 +64,9 @@ services:
volumes:
postgres-data:
uploads-data:
networks:
default:
ipam:
config:
- subnet: 10.89.0.0/24

1553
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,9 @@
},
"dependencies": {
"@prisma/client": "^5.20.0",
"archiver": "^8.0.0",
"bcryptjs": "^2.4.3",
"heic-convert": "^2.1.0",
"libphonenumber-js": "^1.13.8",
"nanoid": "^5.0.7",
"next": "^16.2.10",
@ -26,11 +28,13 @@
"prisma": "^5.20.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "^0.35.3",
"twilio": "^6.0.2",
"web-push": "^3.6.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/archiver": "^8.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.9",
"@types/nodemailer": "^7.0.2",

View File

@ -0,0 +1,5 @@
-- AlterTable: nullable host-chosen color overrides for the event's theme -
-- NULL means "use the theme's own default colors" (see resolveTheme in
-- src/lib/themes.ts).
ALTER TABLE "Event" ADD COLUMN "themePrimaryColor" TEXT,
ADD COLUMN "themeAccentColor" TEXT;

View File

@ -0,0 +1,3 @@
-- AlterTable: nullable WebP thumbnail for photo formats browsers can't
-- render inline (HEIC/HEIF) - see Photo.thumbnailFilename in schema.prisma.
ALTER TABLE "Photo" ADD COLUMN "thumbnailFilename" TEXT;

View File

@ -71,6 +71,8 @@ model Event {
description String @default("")
contactInfo String @default("")
theme String @default("classic-elegant")
themePrimaryColor String?
themeAccentColor String?
timezone String @default("UTC")
eventDate DateTime
endDate DateTime?
@ -99,12 +101,15 @@ model Event {
}
model Photo {
id String @id @default(cuid())
eventId String
filename String
uploaderName String @default("")
hidden Boolean @default(false)
createdAt DateTime @default(now())
id String @id @default(cuid())
eventId String
filename String
// WebP thumbnail filename for formats browsers can't render inline (HEIC/HEIF).
// Null means `filename` itself is already displayable - use it directly.
thumbnailFilename String?
uploaderName String @default("")
hidden Boolean @default(false)
createdAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

View File

@ -19,6 +19,8 @@ function parseEventForm(formData: FormData) {
description: formData.get('description') ?? '',
contactInfo: formData.get('contactInfo') ?? '',
theme: formData.get('theme'),
themePrimaryColor: formData.get('themePrimaryColor') ?? '',
themeAccentColor: formData.get('themeAccentColor') ?? '',
timezone: formData.get('timezone'),
eventDate: formData.get('eventDate'),
endDate: formData.get('endDate') ?? '',
@ -59,6 +61,8 @@ export async function createEventAction(_prev: ActionState, formData: FormData):
description: data.description,
contactInfo: data.contactInfo,
theme: data.theme,
themePrimaryColor: data.themePrimaryColor || null,
themeAccentColor: data.themeAccentColor || null,
timezone: tz,
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
@ -99,6 +103,8 @@ export async function updateEventAction(
description: data.description,
contactInfo: data.contactInfo,
theme: data.theme,
themePrimaryColor: data.themePrimaryColor || null,
themeAccentColor: data.themeAccentColor || null,
timezone: tz,
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,

View File

@ -4,7 +4,15 @@ import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireEventAccess } from '@/lib/eventAccess';
import { isPhotoUploadOpen } from '@/lib/photoWindow';
import { savePhotoFile, deletePhotoFile, MAX_PHOTO_BYTES, ALLOWED_PHOTO_TYPES } from '@/lib/photoStorage';
import {
savePhotoFile,
saveThumbnailFile,
deletePhotoFile,
MAX_PHOTO_BYTES,
resolvePhotoExtension,
isHeicExtension,
} from '@/lib/photoStorage';
import { convertHeicToWebpThumbnail } from '@/lib/photoThumbnail';
import { photoUploadSchema } from '@/lib/validation';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
@ -43,8 +51,9 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
if (!(file instanceof File) || file.size === 0) {
return { error: 'Choose a photo to upload' };
}
if (!ALLOWED_PHOTO_TYPES[file.type]) {
return { error: 'Only JPEG, PNG, WebP, and GIF images are allowed' };
const ext = resolvePhotoExtension(file);
if (!ext) {
return { error: 'Only JPEG, PNG, WebP, GIF, HEIC, and HEIF images are allowed' };
}
if (file.size > MAX_PHOTO_BYTES) {
return { error: 'Photo is too large (8MB max)' };
@ -53,7 +62,22 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
// Write the file first (cheap to redo/discard), then enforce the cap with
// a serializable transaction so a burst of concurrent uploads can't all
// pass a stale count check and land past MAX_PHOTOS_PER_EVENT together.
const filename = await savePhotoFile(eventId, file);
const buffer = Buffer.from(await file.arrayBuffer());
const filename = await savePhotoFile(eventId, buffer, ext);
// Most browsers (everything but Safari) can't render HEIC/HEIF inline, so
// generate a WebP thumbnail for gallery display; the original HEIC/HEIF
// file is kept as-is and is what downloads (single or zip) hand back.
let thumbnailFilename: string | null = null;
if (isHeicExtension(ext)) {
try {
const thumbnailBuffer = await convertHeicToWebpThumbnail(buffer);
thumbnailFilename = await saveThumbnailFile(eventId, thumbnailBuffer);
} catch (err) {
console.error(`Failed to generate thumbnail for HEIC/HEIF photo in event ${eventId}:`, err);
}
}
try {
await prisma.$transaction(
async (tx) => {
@ -61,12 +85,13 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
if (photoCount >= MAX_PHOTOS_PER_EVENT) {
throw new PhotoLimitReachedError();
}
await tx.photo.create({ data: { eventId, filename, uploaderName } });
await tx.photo.create({ data: { eventId, filename, thumbnailFilename, uploaderName } });
},
{ isolationLevel: 'Serializable' }
);
} catch (err) {
await deletePhotoFile(eventId, filename);
if (thumbnailFilename) await deletePhotoFile(eventId, thumbnailFilename);
if (err instanceof PhotoLimitReachedError) {
return { error: 'This event has reached its photo limit' };
}

View File

@ -1,12 +1,16 @@
'use server';
import { notFound } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { rsvpSchema } from '@/lib/validation';
import { rsvpSchema, rsvpUpdateSchema, manualRsvpSchema } from '@/lib/validation';
import { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail';
import { appUrl } from '@/lib/slug';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { sendPushToUser } from '@/lib/push';
import { accessibleEventWhere, requireEventAccess } from '@/lib/eventAccess';
import { requireUser } from '@/lib/session';
import { looksLikeEmail, normalizePhoneNumber } from '@/lib/phone';
export type ActionState = { error?: string; success?: string };
@ -111,3 +115,110 @@ export async function submitRsvpAction(_prev: ActionState, formData: FormData):
revalidatePath(`/invite/${event.slug}`);
return { success: SUCCESS_MESSAGE };
}
async function requireOwnedRsvp(rsvpId: string) {
const user = await requireUser();
const rsvp = await prisma.rsvp.findUnique({ where: { id: rsvpId }, include: { event: true } });
if (!rsvp) notFound();
const accessibleEvent = await prisma.event.findFirst({
where: accessibleEventWhere(rsvp.eventId, user.id),
});
if (!accessibleEvent) notFound();
return rsvp;
}
export type RsvpEditState = { error?: string };
export async function updateRsvpAction(
rsvpId: string,
_prev: RsvpEditState,
formData: FormData
): Promise<RsvpEditState> {
const rsvp = await requireOwnedRsvp(rsvpId);
const parsed = rsvpUpdateSchema.safeParse({
guestName: formData.get('guestName'),
status: formData.get('status'),
numGuests: formData.get('numGuests') || '1',
note: formData.get('note') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const data = parsed.data;
await prisma.rsvp.update({
where: { id: rsvp.id },
data: {
guestName: data.guestName,
status: data.status,
numGuests: data.status === 'NO' ? 1 : data.numGuests,
note: data.note,
},
});
revalidatePath(`/dashboard/events/${rsvp.eventId}/guests`);
revalidatePath(`/invite/${rsvp.event.slug}`);
return {};
}
/**
* Records an RSVP the host heard about outside the app (a text, a call, a
* comment at the mailbox) rather than through the guest submitting the
* form themselves. Upserts on the same email/phone key the guest-facing
* form uses, so if that guest later does submit the form it just updates
* this row instead of creating a duplicate.
*/
export async function addRsvpAction(
eventId: string,
_prev: RsvpEditState,
formData: FormData
): Promise<RsvpEditState> {
const { event } = await requireEventAccess(eventId);
const parsed = manualRsvpSchema.safeParse({
guestName: formData.get('guestName'),
contact: formData.get('contact'),
status: formData.get('status'),
numGuests: formData.get('numGuests') || '1',
note: formData.get('note') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const data = parsed.data;
let guestEmail: string | null = null;
let guestPhone: string | null = null;
if (looksLikeEmail(data.contact)) {
const emailResult = rsvpSchema.shape.guestEmail.safeParse(data.contact);
if (!emailResult.success) return { error: 'Enter a valid email address' };
guestEmail = emailResult.data.toLowerCase();
} else {
guestPhone = normalizePhoneNumber(data.contact);
if (!guestPhone) return { error: 'Enter a valid email address or phone number' };
}
const numGuests = data.status === 'NO' ? 1 : data.numGuests;
await prisma.rsvp.upsert({
where: guestEmail
? { eventId_guestEmail: { eventId: event.id, guestEmail } }
: { eventId_guestPhone: { eventId: event.id, guestPhone: guestPhone as string } },
create: { eventId: event.id, guestName: data.guestName, guestEmail, guestPhone, status: data.status, numGuests, note: data.note },
update: { guestName: data.guestName, status: data.status, numGuests, note: data.note },
});
revalidatePath(`/dashboard/events/${event.id}/guests`);
revalidatePath(`/invite/${event.slug}`);
return {};
}
export async function deleteRsvpAction(rsvpId: string) {
const rsvp = await requireOwnedRsvp(rsvpId);
await prisma.rsvp.delete({ where: { id: rsvp.id } });
revalidatePath(`/dashboard/events/${rsvp.eventId}/guests`);
revalidatePath(`/invite/${rsvp.event.slug}`);
}

View File

@ -0,0 +1,80 @@
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireEventAccess } from '@/lib/eventAccess';
import { sendThankYouEmail } from '@/lib/mail';
import { sendSms } from '@/lib/sms';
import { appUrl } from '@/lib/slug';
import { sendThankYouSchema } from '@/lib/validation';
import { checkRateLimit } from '@/lib/rateLimit';
export type ActionState = { error?: string; success?: string };
// Reaches everyone who RSVP'd yes, by whichever contact method they used to
// RSVP (email form vs. SMS reply) - same "message the channel they gave us"
// approach as sendInvitesAction, just for the guest list already on file
// instead of a freshly pasted one.
export async function sendThankYouAction(
eventId: string,
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const { user, event } = await requireEventAccess(eventId);
const parsed = sendThankYouSchema.safeParse({
eventId,
message: formData.get('message'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
const recipients = await prisma.rsvp.findMany({
where: { eventId, status: 'YES' },
select: { guestName: true, guestEmail: true, guestPhone: true },
});
if (recipients.length === 0) {
return { error: "No guests have RSVP'd yes yet - nothing to send." };
}
if (!checkRateLimit(`send-thankyou:${user.id}`, 200, 60 * 60 * 1000, recipients.length)) {
return { error: 'Too many messages sent recently. Please try again later.' };
}
const inviteUrl = appUrl(`/invite/${event.slug}`);
let emailed = 0;
let emailFailed = 0;
let texted = 0;
let smsFailed = 0;
for (const recipient of recipients) {
if (recipient.guestEmail) {
const delivered = await sendThankYouEmail({
to: recipient.guestEmail,
guestName: recipient.guestName,
hostName: user.name,
eventTitle: event.title,
message: parsed.data.message,
inviteUrl,
});
if (delivered) emailed++;
else emailFailed++;
} else if (recipient.guestPhone) {
const delivered = await sendSms(recipient.guestPhone, `${user.name}: ${parsed.data.message}`);
if (delivered) texted++;
else smsFailed++;
}
}
revalidatePath(`/dashboard/events/${event.id}/guests`);
const parts: string[] = [];
if (emailed > 0) parts.push(`Emailed ${emailed}.`);
if (texted > 0) parts.push(`Texted ${texted}.`);
if (emailFailed > 0) parts.push(`${emailFailed} email(s) failed - check your SMTP settings.`);
if (smsFailed > 0) parts.push(`${smsFailed} text(s) failed - check your Twilio settings.`);
if (parts.length === 0) parts.push('No messages could be delivered.');
return { success: parts.join(' ') };
}

View File

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireEventAccess } from '@/lib/eventAccess';
import { buildPhotoZipStream } from '@/lib/photoZip';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
export const runtime = 'nodejs';
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { event } = await requireEventAccess(id);
const ip = await getClientIp();
if (!checkRateLimit(`photo-zip:ip:${ip}`, 5, 5 * 60 * 1000)) {
return NextResponse.json({ error: 'Too many download requests. Please wait a bit and try again.' }, { status: 429 });
}
// Hosts get everything, including hidden photos - they're the ones
// managing them and may want a copy before permanently deleting one.
const photos = await prisma.photo.findMany({ where: { eventId: id }, orderBy: { createdAt: 'asc' } });
if (photos.length === 0) {
return NextResponse.json({ error: 'No photos to download' }, { status: 404 });
}
const stream = buildPhotoZipStream(
event.slug,
photos.map((p) => ({ eventId: id, filename: p.filename, uploaderName: p.uploaderName, createdAt: p.createdAt }))
);
return new Response(stream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${event.slug}-photos.zip"`,
},
});
}

View File

@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireEventAccess } from '@/lib/eventAccess';
// Quote every field per RFC 4180 and double up embedded quotes - simplest
// rule that's correct for names/notes containing commas, quotes, or newlines.
function csvField(value: string): string {
return `"${value.replace(/"/g, '""')}"`;
}
function csvRow(fields: string[]): string {
return fields.map(csvField).join(',') + '\r\n';
}
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { event } = await requireEventAccess(id);
const rsvps = await prisma.rsvp.findMany({ where: { eventId: id }, orderBy: { createdAt: 'asc' } });
let csv = csvRow(['Name', 'Status', 'Guests', 'Email', 'Phone', 'Note', 'Responded at (UTC)']);
for (const rsvp of rsvps) {
csv += csvRow([
rsvp.guestName,
rsvp.status,
String(rsvp.numGuests),
rsvp.guestEmail ?? '',
rsvp.guestPhone ?? '',
rsvp.note,
rsvp.createdAt.toISOString(),
]);
}
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${event.slug}-rsvps.csv"`,
},
});
}

View File

@ -15,6 +15,8 @@ export default async function EditEventPage({ params }: { params: Promise<{ id:
description: event.description,
contactInfo: event.contactInfo,
theme: event.theme,
themePrimaryColor: event.themePrimaryColor ?? '',
themeAccentColor: event.themeAccentColor ?? '',
timezone: tz,
eventDate: utcToZonedDatetimeLocal(event.eventDate, tz),
endDate: event.endDate ? utcToZonedDatetimeLocal(event.endDate, tz) : '',

View File

@ -4,19 +4,23 @@ import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
import { accessibleEventWhere } from '@/lib/eventAccess';
import { appUrl } from '@/lib/slug';
import { getTheme } from '@/lib/themes';
import { resolveTheme } from '@/lib/themes';
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
import { ShareLinkBox } from '@/components/ShareLinkBox';
import { SendInvitesForm } from '@/components/SendInvitesForm';
import { InviteCollaboratorForm } from '@/components/InviteCollaboratorForm';
import { regenerateSlugAction, deleteEventAction } from '@/app/actions/events';
import { sendInvitesAction, removeGuestFromListAction } from '@/app/actions/invites';
import { sendThankYouAction } from '@/app/actions/thankyou';
import { ThankYouForm } from '@/components/ThankYouForm';
import { toggleCommentHiddenAction, deleteCommentAction } from '@/app/actions/comments';
import { inviteCollaboratorAction, removeCollaboratorAction } from '@/app/actions/collaborators';
import { togglePhotoHiddenAction, deletePhotoAction, toggleForceCloseAction } from '@/app/actions/photos';
import { formatEventDateRange } from '@/lib/format';
import { isPhotoUploadOpen } from '@/lib/photoWindow';
import { photoUrl } from '@/lib/photoStorage';
import { photoDisplayUrl } from '@/lib/photoStorage';
import { RsvpRow } from '@/components/RsvpRow';
import { AddRsvpForm } from '@/components/AddRsvpForm';
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@ -37,16 +41,18 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
const isPrimaryHost = event.hostId === user.id;
const photosOpen = isPhotoUploadOpen(event);
const theme = getTheme(event.theme);
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
const inviteUrl = appUrl(`/invite/${event.slug}`);
const tally = {
YES: event.rsvps.filter((r) => r.status === 'YES').reduce((s, r) => s + r.numGuests, 0),
NO: event.rsvps.filter((r) => r.status === 'NO').length,
MAYBE: event.rsvps.filter((r) => r.status === 'MAYBE').reduce((s, r) => s + r.numGuests, 0),
};
const yesRsvpCount = event.rsvps.filter((r) => r.status === 'YES').length;
const boundRegenerate = regenerateSlugAction.bind(null, event.id);
const boundSendInvites = sendInvitesAction.bind(null, event.id);
const boundSendThankYou = sendThankYouAction.bind(null, event.id);
const boundInviteCollaborator = inviteCollaboratorAction.bind(null, event.id);
const boundToggleForceClose = toggleForceCloseAction.bind(null, event.id);
@ -185,14 +191,24 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
<h2 className="font-semibold text-gray-900">
RSVPs
{event.restrictRsvpToInvited && (
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
Restricted to guest list
</span>
<div className="flex items-start justify-between gap-4">
<h2 className="font-semibold text-gray-900">
RSVPs
{event.restrictRsvpToInvited && (
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
Restricted to guest list
</span>
)}
</h2>
{event.rsvps.length > 0 && (
<a
href={`/api/events/${event.id}/rsvps/export`}
className="shrink-0 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
Export CSV
</a>
)}
</h2>
</div>
<div className="mt-2 flex gap-6 text-sm">
<span className="text-green-700">{tally.YES} attending</span>
<span className="text-amber-700">{tally.MAYBE} maybe</span>
@ -208,23 +224,34 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
<th className="py-2 font-medium">Status</th>
<th className="py-2 font-medium">Guests</th>
<th className="py-2 font-medium">Note</th>
<th className="py-2 font-medium" />
</tr>
</thead>
<tbody>
{event.rsvps.map((rsvp) => (
<tr key={rsvp.id} className="border-b last:border-0">
<td className="py-2">
<div>{rsvp.guestName}</div>
<div className="text-xs text-gray-400">{rsvp.guestEmail}</div>
</td>
<td className="py-2">{rsvp.status}</td>
<td className="py-2">{rsvp.numGuests}</td>
<td className="py-2 text-gray-500">{rsvp.note}</td>
</tr>
<RsvpRow key={rsvp.id} rsvp={rsvp} />
))}
</tbody>
</table>
)}
<div className="mt-4">
<AddRsvpForm eventId={event.id} />
</div>
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
<h2 className="font-semibold text-gray-900">Thank guests</h2>
<p className="mt-1 text-sm text-gray-500">
Send a message to everyone who RSVP&apos;d yes, by whichever email or phone number they
used to RSVP. Handy for a post-event thank-you, but you can send it any time.
</p>
{yesRsvpCount === 0 ? (
<p className="mt-4 text-sm text-gray-500">No one has RSVP&apos;d yes yet.</p>
) : (
<div className="mt-3">
<ThankYouForm action={boundSendThankYou} recipientCount={yesRsvpCount} />
</div>
)}
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
@ -265,18 +292,28 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
</section>
<section className="rounded-lg p-5" style={adminCardStyle}>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<h2 className="font-semibold text-gray-900">Photos</h2>
{event.photosEnabled && (
<form action={boundToggleForceClose}>
<button
type="submit"
<div className="flex items-center gap-2">
{event.photos.length > 0 && (
<a
href={`/api/events/${event.id}/photos/zip`}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
{event.photosClosed ? 'Reopen uploads' : 'Close uploads now'}
</button>
</form>
)}
Download all ({event.photos.length})
</a>
)}
{event.photosEnabled && (
<form action={boundToggleForceClose}>
<button
type="submit"
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
{event.photosClosed ? 'Reopen uploads' : 'Close uploads now'}
</button>
</form>
)}
</div>
</div>
<p className="mt-1 text-sm text-gray-500">
{!event.photosEnabled
@ -297,11 +334,16 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
<div key={photo.id} className={`relative aspect-square overflow-hidden rounded-md ${photo.hidden ? 'opacity-40' : ''}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photoUrl(event.id, photo.filename)}
src={photoDisplayUrl(event.id, photo)}
alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'}
className="h-full w-full object-cover"
loading="lazy"
/>
{photo.uploaderName && (
<span className="absolute inset-x-0 top-0 truncate bg-black/50 px-2 py-1 text-xs text-white">
{photo.uploaderName}
</span>
)}
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-2 py-1 text-xs text-white">
<form action={togglePhotoHiddenAction.bind(null, photo.id)}>
<button type="submit" className="hover:underline">

View File

@ -1,7 +1,7 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/session';
import { getTheme } from '@/lib/themes';
import { resolveTheme } from '@/lib/themes';
import { PageContainer } from '@/components/PageContainer';
export default async function DashboardPage() {
@ -41,7 +41,7 @@ export default async function DashboardPage() {
) : (
<ul className="mt-6 flex flex-col gap-3">
{events.map((event) => {
const theme = getTheme(event.theme);
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
const yesCount = event.rsvps
.filter((r) => r.status === 'YES')
.reduce((sum, r) => sum + r.numGuests, 0);

View File

@ -0,0 +1,28 @@
import { notFound } from 'next/navigation';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { buildIcs } from '@/lib/ics';
import { appUrl } from '@/lib/slug';
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const event = await prisma.event.findUnique({ where: { slug } });
if (!event) notFound();
const ics = buildIcs({
uid: `${event.id}@e-invite`,
title: event.title,
description: event.description,
location: event.location,
start: event.eventDate,
end: event.endDate,
url: appUrl(`/invite/${event.slug}`),
});
return new NextResponse(ics, {
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${event.slug}.ics"`,
},
});
}

View File

@ -1,6 +1,6 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { getTheme } from '@/lib/themes';
import { resolveTheme } from '@/lib/themes';
import { ThemedInviteLayout } from '@/components/ThemedInviteLayout';
import { ThemeDivider } from '@/components/ThemeDivider';
import { themeCardStyle as cardStyle } from '@/components/ThemeBackground';
@ -10,7 +10,10 @@ import { PhotoUploadForm } from '@/components/PhotoUploadForm';
import { PhotoGallery } from '@/components/PhotoGallery';
import { formatEventDateRange } from '@/lib/format';
import { isPhotoUploadOpen } from '@/lib/photoWindow';
import { photoUrl } from '@/lib/photoStorage';
import { photoDisplayUrl } from '@/lib/photoStorage';
import { googleCalendarUrl, mapsUrl } from '@/lib/ics';
import { appUrl } from '@/lib/slug';
import { EventCountdown } from '@/components/EventCountdown';
export default async function PublicInvitePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
@ -25,7 +28,7 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
});
if (!event) notFound();
const theme = getTheme(event.theme);
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
const rsvpClosed = Boolean(event.rsvpDeadline && event.rsvpDeadline < new Date());
const photosOpen = isPhotoUploadOpen(event);
const showPhotos = event.photosEnabled && (photosOpen || event.photos.length > 0);
@ -43,7 +46,40 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
<h1 className="mt-2 break-words text-3xl font-bold sm:text-4xl">{event.title}</h1>
{event.occasion && <p className="mt-1 italic opacity-80">{event.occasion}</p>}
<p className="mt-3 text-lg">{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}</p>
{event.location && <p className="mt-1 opacity-80">{event.location}</p>}
<EventCountdown eventDate={event.eventDate.toISOString()} endDate={event.endDate?.toISOString() ?? null} />
{event.location && (
<p className="mt-1 opacity-80">
<a href={mapsUrl(event.location)} target="_blank" rel="noopener noreferrer" className="underline decoration-dotted hover:opacity-80">
{event.location}
</a>
</p>
)}
<div className="mt-4 flex flex-wrap justify-center gap-2 text-xs">
<a
href={`/invite/${event.slug}/ics`}
className="rounded-full px-3 py-1 font-medium"
style={{ background: 'var(--theme-bg)', border: '1px solid var(--theme-accent)' }}
>
📅 Add to calendar
</a>
<a
href={googleCalendarUrl({
uid: event.id,
title: event.title,
description: event.description,
location: event.location,
start: event.eventDate,
end: event.endDate,
url: appUrl(`/invite/${event.slug}`),
})}
target="_blank"
rel="noopener noreferrer"
className="rounded-full px-3 py-1 font-medium"
style={{ background: 'var(--theme-bg)', border: '1px solid var(--theme-accent)' }}
>
Add to Google Calendar
</a>
</div>
{(event.foodProvided || event.drinksProvided) && (
<div className="mt-3 flex flex-wrap justify-center gap-2">
{event.foodProvided && (
@ -118,7 +154,17 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
<>
<ThemeDivider theme={theme} />
<div className="reveal rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.4s' }}>
<h2 className="text-xl font-semibold">Photos</h2>
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">Photos</h2>
{event.photos.length > 0 && (
<a
href={`/invite/${event.slug}/photos/zip`}
className="shrink-0 rounded-full px-3 py-1 text-xs font-medium underline decoration-dotted opacity-80 hover:opacity-100"
>
Download all ({event.photos.length})
</a>
)}
</div>
{photosOpen ? (
<div className="mt-4">
<PhotoUploadForm eventId={event.id} />
@ -129,7 +175,7 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
<PhotoGallery
photos={event.photos.map((p) => ({
id: p.id,
url: photoUrl(event.id, p.filename),
url: photoDisplayUrl(event.id, p),
uploaderName: p.uploaderName,
}))}
/>

View File

@ -0,0 +1,36 @@
import { notFound } from 'next/navigation';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { buildPhotoZipStream } from '@/lib/photoZip';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
export const runtime = 'nodejs';
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const event = await prisma.event.findUnique({ where: { slug } });
if (!event || !event.photosEnabled) notFound();
const ip = await getClientIp();
const rateLimited =
!checkRateLimit(`photo-zip:ip:${ip}`, 5, 5 * 60 * 1000) || !checkRateLimit(`photo-zip:event:${event.id}`, 20, 5 * 60 * 1000);
if (rateLimited) {
return NextResponse.json({ error: 'Too many download requests. Please wait a bit and try again.' }, { status: 429 });
}
// Guests only ever see (and can only download) photos the host hasn't hidden.
const photos = await prisma.photo.findMany({ where: { eventId: event.id, hidden: false }, orderBy: { createdAt: 'asc' } });
if (photos.length === 0) notFound();
const stream = buildPhotoZipStream(
event.slug,
photos.map((p) => ({ eventId: event.id, filename: p.filename, uploaderName: p.uploaderName, createdAt: p.createdAt }))
);
return new Response(stream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${event.slug}-photos.zip"`,
},
});
}

View File

@ -0,0 +1,85 @@
'use client';
import { useActionState, useEffect, useRef, useState } from 'react';
import { addRsvpAction, type RsvpEditState } from '@/app/actions/rsvp';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: RsvpEditState = {};
const inputClass =
'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none';
export function AddRsvpForm({ eventId }: { eventId: string }) {
const action = addRsvpAction.bind(null, eventId);
const [state, formAction, isPending] = useActionState(action, initialState);
const [open, setOpen] = useState(false);
// See EditRsvpForm for why we track isPending instead of awaiting
// formAction directly: its dispatch doesn't return a promise.
const wasPending = useRef(false);
useEffect(() => {
if (wasPending.current && !isPending && !state.error) {
setOpen(false);
}
wasPending.current = isPending;
}, [isPending, state.error]);
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
Add an RSVP
</button>
);
}
return (
<form action={formAction} className="flex flex-col gap-3 rounded-md border border-gray-200 p-4">
<p className="text-xs text-gray-500">
For guests who let you know their response directly instead of using the invite link.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-500">Name</label>
<input name="guestName" required className={inputClass} />
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Email or phone</label>
<input name="contact" required className={inputClass} />
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Status</label>
<select name="status" defaultValue="YES" className={inputClass}>
<option value="YES">Yes</option>
<option value="MAYBE">Maybe</option>
<option value="NO">Can&apos;t make it</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Guests</label>
<input name="numGuests" type="number" min={1} max={50} defaultValue={1} className={inputClass} />
</div>
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-gray-500">Note (optional)</label>
<input name="note" className={inputClass} />
</div>
</div>
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
<div className="flex gap-2">
<SubmitButton className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
Add RSVP
</SubmitButton>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,88 @@
'use client';
import { useActionState, useEffect, useRef } from 'react';
import { updateRsvpAction, type RsvpEditState } from '@/app/actions/rsvp';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: RsvpEditState = {};
const inputClass =
'w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none';
export function EditRsvpForm({
rsvpId,
guestName,
status,
numGuests,
note,
onDone,
}: {
rsvpId: string;
guestName: string;
status: 'YES' | 'NO' | 'MAYBE';
numGuests: number;
note: string;
onDone: () => void;
}) {
const action = updateRsvpAction.bind(null, rsvpId);
const [state, formAction, isPending] = useActionState(action, initialState);
// useActionState's dispatch doesn't return a promise we can await, so we
// can't just chain onDone() after calling it - instead watch isPending
// fall back to false (the submission finished) and only close if it
// succeeded, so a validation error stays visible.
const wasPending = useRef(false);
useEffect(() => {
if (wasPending.current && !isPending && !state.error) {
onDone();
}
wasPending.current = isPending;
}, [isPending, state.error, onDone]);
return (
<form action={formAction} className="flex flex-col gap-2 rounded-md bg-gray-50 p-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="col-span-2 sm:col-span-1">
<label className="block text-xs font-medium text-gray-500">Name</label>
<input name="guestName" defaultValue={guestName} required className={inputClass} />
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Status</label>
<select name="status" defaultValue={status} className={inputClass}>
<option value="YES">Yes</option>
<option value="MAYBE">Maybe</option>
<option value="NO">Can&apos;t make it</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Guests</label>
<input
name="numGuests"
type="number"
min={1}
max={50}
defaultValue={numGuests}
className={inputClass}
/>
</div>
<div className="col-span-2 sm:col-span-1">
<label className="block text-xs font-medium text-gray-500">Note</label>
<input name="note" defaultValue={note} className={inputClass} />
</div>
</div>
{state.error && <p className="text-xs text-red-700">{state.error}</p>}
<div className="flex gap-2">
<SubmitButton className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
Save
</SubmitButton>
<button
type="button"
onClick={onDone}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import { useEffect, useState } from 'react';
function timeLeft(target: Date) {
const ms = target.getTime() - Date.now();
const totalMinutes = Math.floor(ms / 60000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
return { days, hours, minutes };
}
function formatCountdown({ days, hours, minutes }: { days: number; hours: number; minutes: number }) {
const parts: string[] = [];
if (days > 0) parts.push(`${days} day${days === 1 ? '' : 's'}`);
if (days > 0 || hours > 0) parts.push(`${hours} hour${hours === 1 ? '' : 's'}`);
parts.push(`${minutes} minute${minutes === 1 ? '' : 's'}`);
return parts.join(', ');
}
export function EventCountdown({ eventDate, endDate }: { eventDate: string; endDate: string | null }) {
const start = new Date(eventDate);
const end = endDate ? new Date(endDate) : null;
// Server-rendered markup can't know "now", so render nothing until the
// client mounts and computes a real countdown - avoids a hydration
// mismatch from comparing against two different clocks.
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
setNow(Date.now());
const interval = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(interval);
}, []);
if (now === null) return null;
if (now < start.getTime()) {
return <p className="mt-2 text-sm font-medium opacity-80">Starts in {formatCountdown(timeLeft(start))}</p>;
}
if (end && now < end.getTime()) {
return <p className="mt-2 text-sm font-medium opacity-80">Happening now!</p>;
}
return null;
}

View File

@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { getTheme, DEFAULT_THEME_ID } from '@/lib/themes';
import { resolveTheme, DEFAULT_THEME_ID } from '@/lib/themes';
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
import { EventForm, type EventActionState, type EventFormDefaults } from '@/components/EventForm';
@ -17,14 +17,25 @@ export function EventEditor({
submitLabel: string;
}) {
const [themeId, setThemeId] = useState(defaults?.theme ?? DEFAULT_THEME_ID);
const theme = getTheme(themeId);
const [primaryColor, setPrimaryColor] = useState<string | null>(defaults?.themePrimaryColor || null);
const [accentColor, setAccentColor] = useState<string | null>(defaults?.themeAccentColor || null);
const theme = resolveTheme(themeId, primaryColor, accentColor);
return (
<ThemeBackground theme={theme} applyTextStyle={false} className="flex-1 p-6 sm:p-10">
<div className="mx-auto max-w-2xl rounded-lg p-6" style={adminCardStyle}>
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
<div className="mt-6">
<EventForm action={action} defaults={defaults} submitLabel={submitLabel} onThemeChange={setThemeId} />
<EventForm
action={action}
defaults={defaults}
submitLabel={submitLabel}
onThemeChange={setThemeId}
onColorsChange={(primary, accent) => {
setPrimaryColor(primary);
setAccentColor(accent);
}}
/>
</div>
</div>
</ThemeBackground>

View File

@ -3,7 +3,7 @@
import { useActionState, useEffect, useState } from 'react';
import { ThemePicker } from '@/components/ThemePicker';
import { SubmitButton } from '@/components/SubmitButton';
import { DEFAULT_THEME_ID } from '@/lib/themes';
import { getTheme, DEFAULT_THEME_ID } from '@/lib/themes';
import { TIMEZONES } from '@/lib/timezone';
export type EventActionState = { error?: string };
@ -14,6 +14,9 @@ export type EventFormDefaults = {
description: string;
contactInfo: string;
theme: string;
/** '' means "use the theme's own default color". */
themePrimaryColor: string;
themeAccentColor: string;
timezone: string;
eventDate: string;
endDate: string;
@ -34,6 +37,8 @@ const emptyDefaults: EventFormDefaults = {
description: '',
contactInfo: '',
theme: DEFAULT_THEME_ID,
themePrimaryColor: '',
themeAccentColor: '',
timezone: '',
eventDate: '',
endDate: '',
@ -53,11 +58,13 @@ export function EventForm({
defaults = emptyDefaults,
submitLabel,
onThemeChange,
onColorsChange,
}: {
action: (state: EventActionState, formData: FormData) => Promise<EventActionState>;
defaults?: EventFormDefaults;
submitLabel: string;
onThemeChange?: (themeId: string) => void;
onColorsChange?: (primary: string | null, accent: string | null) => void;
}) {
const [state, formAction] = useActionState(action, {});
// New events (empty default) auto-detect the browser's zone; edits keep
@ -69,6 +76,32 @@ export function EventForm({
}
}, [defaults.timezone]);
// Mirrors ThemePicker's own selection state so the color pickers below
// know which theme's default colors to fall back to / preview against.
const [themeId, setThemeId] = useState(defaults.theme);
// null = "use the selected theme's own default color", not yet customized.
const [primaryColor, setPrimaryColor] = useState<string | null>(defaults.themePrimaryColor || null);
const [accentColor, setAccentColor] = useState<string | null>(defaults.themeAccentColor || null);
const selectedTheme = getTheme(themeId);
function handleThemeChange(id: string) {
setThemeId(id);
setPrimaryColor(null);
setAccentColor(null);
onThemeChange?.(id);
onColorsChange?.(null, null);
}
function handlePrimaryChange(hex: string | null) {
setPrimaryColor(hex);
onColorsChange?.(hex, accentColor);
}
function handleAccentChange(hex: string | null) {
setAccentColor(hex);
onColorsChange?.(primaryColor, hex);
}
return (
<form action={formAction} className="flex flex-col gap-6">
<div>
@ -224,10 +257,57 @@ export function EventForm({
<div>
<span className="block text-sm font-medium text-gray-700">Theme</span>
<div className="mt-2">
<ThemePicker defaultThemeId={defaults.theme} onChange={onThemeChange} />
<ThemePicker defaultThemeId={defaults.theme} onChange={handleThemeChange} />
</div>
</div>
<div className="grid grid-cols-2 gap-4 sm:max-w-xs">
<div>
<label htmlFor="themePrimaryColorInput" className="block text-sm font-medium text-gray-700">
Primary color
</label>
<div className="mt-1 flex items-center gap-2">
<input
id="themePrimaryColorInput"
type="color"
value={primaryColor ?? selectedTheme.colors.primary}
onChange={(e) => handlePrimaryChange(e.target.value)}
className="h-9 w-14 cursor-pointer rounded border border-gray-300 p-0.5"
/>
{primaryColor && (
<button type="button" onClick={() => handlePrimaryChange(null)} className="text-xs text-gray-500 hover:text-gray-800">
Reset
</button>
)}
</div>
<input type="hidden" name="themePrimaryColor" value={primaryColor ?? ''} />
</div>
<div>
<label htmlFor="themeAccentColorInput" className="block text-sm font-medium text-gray-700">
Accent color
</label>
<div className="mt-1 flex items-center gap-2">
<input
id="themeAccentColorInput"
type="color"
value={accentColor ?? selectedTheme.colors.accent}
onChange={(e) => handleAccentChange(e.target.value)}
className="h-9 w-14 cursor-pointer rounded border border-gray-300 p-0.5"
/>
{accentColor && (
<button type="button" onClick={() => handleAccentChange(null)} className="text-xs text-gray-500 hover:text-gray-800">
Reset
</button>
)}
</div>
<input type="hidden" name="themeAccentColor" value={accentColor ?? ''} />
</div>
</div>
<p className="-mt-4 text-xs text-gray-500">
Optional - override the theme&apos;s own colors, e.g. if the default palette doesn&apos;t
fit your event. The background and text stay auto-adjusted for readability.
</p>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input

View File

@ -17,7 +17,8 @@ export function PhotoGallery({ photos }: { photos: GalleryPhoto[] }) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo.url} alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'} className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105" loading="lazy" />
{photo.uploaderName && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
// Always visible, not hover-only - most guests view this on a phone.
<span className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-xs text-white">
{photo.uploaderName}
</span>
)}

View File

@ -20,7 +20,7 @@ export function PhotoUploadForm({ eventId }: { eventId: string }) {
id="photo"
name="photo"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,image/heif,.heic,.heif"
required
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-sm text-gray-900 focus:outline-none"
/>

View File

@ -0,0 +1,75 @@
'use client';
import { useState } from 'react';
import { deleteRsvpAction } from '@/app/actions/rsvp';
import { EditRsvpForm } from '@/components/EditRsvpForm';
import { RsvpStatusPill } from '@/components/RsvpStatusPill';
type Status = 'YES' | 'NO' | 'MAYBE';
export function RsvpRow({
rsvp,
}: {
rsvp: {
id: string;
guestName: string;
guestEmail: string | null;
guestPhone: string | null;
status: Status;
numGuests: number;
note: string;
};
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<tr className="border-b last:border-0">
<td colSpan={5} className="py-2">
<EditRsvpForm
rsvpId={rsvp.id}
guestName={rsvp.guestName}
status={rsvp.status}
numGuests={rsvp.numGuests}
note={rsvp.note}
onDone={() => setEditing(false)}
/>
</td>
</tr>
);
}
return (
<tr className="border-b last:border-0">
<td className="py-2">
<div>{rsvp.guestName}</div>
<div className="text-xs text-gray-400">{rsvp.guestEmail ?? rsvp.guestPhone}</div>
</td>
<td className="py-2">
<RsvpStatusPill status={rsvp.status} />
</td>
<td className="py-2">{rsvp.numGuests}</td>
<td className="py-2 text-gray-500">{rsvp.note}</td>
<td className="py-2 text-right">
<div className="flex justify-end gap-3 text-xs">
<button type="button" onClick={() => setEditing(true)} className="text-gray-500 hover:text-gray-800">
Edit
</button>
<form action={deleteRsvpAction.bind(null, rsvp.id)}>
<button
type="submit"
onClick={(e) => {
if (!confirm(`Remove ${rsvp.guestName}'s RSVP? This can't be undone.`)) {
e.preventDefault();
}
}}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</form>
</div>
</td>
</tr>
);
}

View File

@ -0,0 +1,19 @@
const STYLES: Record<'YES' | 'NO' | 'MAYBE', string> = {
YES: 'bg-green-100 text-green-800',
MAYBE: 'bg-amber-100 text-amber-800',
NO: 'bg-gray-100 text-gray-600',
};
const LABELS: Record<'YES' | 'NO' | 'MAYBE', string> = {
YES: 'Yes',
MAYBE: 'Maybe',
NO: "Can't make it",
};
export function RsvpStatusPill({ status }: { status: 'YES' | 'NO' | 'MAYBE' }) {
return (
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STYLES[status]}`}>
{LABELS[status]}
</span>
);
}

View File

@ -0,0 +1,36 @@
'use client';
import { useActionState } from 'react';
import { SubmitButton } from '@/components/SubmitButton';
import type { ActionState } from '@/app/actions/thankyou';
export function ThankYouForm({
action,
recipientCount,
}: {
action: (state: ActionState, formData: FormData) => Promise<ActionState>;
recipientCount: number;
}) {
const [state, formAction] = useActionState(action, {});
return (
<form action={formAction} className="flex flex-col gap-3">
<label htmlFor="thankYouMessage" className="text-sm font-medium text-gray-700">
Message
</label>
<textarea
id="thankYouMessage"
name="message"
rows={4}
maxLength={2000}
placeholder="Thanks so much for celebrating with us!"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
/>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
{state.success && <p className="text-sm text-green-700">{state.success}</p>}
<SubmitButton className="self-start rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
Send to {recipientCount} guest{recipientCount === 1 ? '' : 's'} who RSVP&apos;d yes
</SubmitButton>
</form>
);
}

49
src/lib/color.test.ts Normal file
View File

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { isValidHexColor, mix, deriveThemeColors } from '@/lib/color';
describe('isValidHexColor', () => {
it('accepts a well-formed 6-digit hex color', () => {
expect(isValidHexColor('#a1b2c3')).toBe(true);
expect(isValidHexColor('#FFFFFF')).toBe(true);
});
it('rejects malformed values', () => {
expect(isValidHexColor('#abc')).toBe(false);
expect(isValidHexColor('abcdef')).toBe(false);
expect(isValidHexColor('#gggggg')).toBe(false);
expect(isValidHexColor('red')).toBe(false);
});
});
describe('mix', () => {
it('returns the source color at t=0 and the target at t=1', () => {
expect(mix('#112233', '#ffffff', 0)).toBe('#112233');
expect(mix('#112233', '#ffffff', 1)).toBe('#ffffff');
});
it('blends proportionally at t=0.5', () => {
expect(mix('#000000', '#ffffff', 0.5)).toBe('#808080');
});
});
describe('deriveThemeColors', () => {
it('produces a near-white background and near-black text for light mode', () => {
const colors = deriveThemeColors('light', '#2563eb', '#f59e0b');
expect(colors.primary).toBe('#2563eb');
expect(colors.accent).toBe('#f59e0b');
// bg should be very light (close to white) regardless of the chosen hue
const bgLuminance = parseInt(colors.bg.slice(1, 3), 16);
expect(bgLuminance).toBeGreaterThan(200);
// text should be very dark for contrast against that light background
const textLuminance = parseInt(colors.text.slice(1, 3), 16);
expect(textLuminance).toBeLessThan(80);
});
it('produces a near-black background and near-white text for dark mode', () => {
const colors = deriveThemeColors('dark', '#e63946', '#f1c40f');
const bgLuminance = parseInt(colors.bg.slice(1, 3), 16);
expect(bgLuminance).toBeLessThan(60);
const textLuminance = parseInt(colors.text.slice(1, 3), 16);
expect(textLuminance).toBeGreaterThan(200);
});
});

54
src/lib/color.ts Normal file
View File

@ -0,0 +1,54 @@
// Small hex-color math for deriving a full theme palette from just the two
// colors a host actually chooses (primary + accent) - see
// deriveThemeColors. No external color library; these are simple enough to
// hand-roll and keep the app dependency-free.
export function isValidHexColor(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value);
}
function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex(r: number, g: number, b: number): string {
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('');
}
/** Blends `hex` toward `target` by `t` (0 = hex, 1 = target). */
export function mix(hex: string, target: string, t: number): string {
const [r1, g1, b1] = hexToRgb(hex);
const [r2, g2, b2] = hexToRgb(target);
return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
}
export type ThemeColors = { bg: string; surface: string; primary: string; accent: string; text: string };
/**
* Builds a full 5-color palette from just the two colors a host actually
* picks (primary + accent), so a custom color choice always stays legible
* instead of requiring the host to also get background/text contrast right
* themselves. `mode` mirrors the theme's own default palette (light card vs.
* dark card) so recoloring "Holiday Festive" still reads as a dark theme,
* not an inverted one.
*/
export function deriveThemeColors(mode: 'light' | 'dark', primary: string, accent: string): ThemeColors {
if (mode === 'light') {
return {
bg: mix(primary, '#ffffff', 0.93),
surface: mix(primary, '#ffffff', 0.99),
primary,
accent,
text: mix(primary, '#000000', 0.75),
};
}
return {
bg: mix(primary, '#000000', 0.88),
surface: mix(primary, '#000000', 0.8),
primary,
accent,
text: mix(primary, '#ffffff', 0.9),
};
}

View File

@ -5,7 +5,11 @@ import {
Inter,
Mountains_of_Christmas,
Special_Elite,
Press_Start_2P,
VT323,
Cormorant_Garamond,
Fredoka,
Zilla_Slab,
Montserrat,
} from 'next/font/google';
const playfairDisplay = Playfair_Display({ subsets: ['latin'], variable: '--font-classic-elegant' });
@ -19,7 +23,19 @@ const mountainsOfChristmas = Mountains_of_Christmas({
});
const quicksandBaby = Quicksand({ subsets: ['latin'], variable: '--font-baby-shower-pastel' });
const specialElite = Special_Elite({ subsets: ['latin'], weight: '400', variable: '--font-goth-grunge' });
const pressStart2p = Press_Start_2P({ subsets: ['latin'], weight: '400', variable: '--font-retro-arcade' });
// Press Start 2P (an actual pixel-block font) reads fine for a short
// logo-sized heading but is close to illegible for paragraphs of body
// copy - VT323 keeps the retro-terminal/arcade feel while staying
// readable at normal text sizes.
const vt323 = VT323({ subsets: ['latin'], weight: '400', variable: '--font-retro-arcade' });
const cormorantGaramond = Cormorant_Garamond({
subsets: ['latin'],
weight: ['400', '600'],
variable: '--font-wedding-romance',
});
const fredoka = Fredoka({ subsets: ['latin'], variable: '--font-tropical-luau' });
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['400', '600'], variable: '--font-rustic-harvest' });
const montserrat = Montserrat({ subsets: ['latin'], variable: '--font-graduation-grad' });
// All font variable classes need to be present in the document for
// next/font's generated CSS variables to be available, so we export one
@ -32,7 +48,11 @@ export const allFontVariables = [
mountainsOfChristmas.variable,
quicksandBaby.variable,
specialElite.variable,
pressStart2p.variable,
vt323.variable,
cormorantGaramond.variable,
fredoka.variable,
zillaSlab.variable,
montserrat.variable,
].join(' ');
const FONT_FAMILY_BY_THEME: Record<string, string> = {
@ -44,6 +64,10 @@ const FONT_FAMILY_BY_THEME: Record<string, string> = {
'baby-shower-pastel': 'var(--font-baby-shower-pastel)',
'goth-grunge': 'var(--font-goth-grunge)',
'retro-arcade': 'var(--font-retro-arcade)',
'wedding-romance': 'var(--font-wedding-romance)',
'tropical-luau': 'var(--font-tropical-luau)',
'rustic-harvest': 'var(--font-rustic-harvest)',
'graduation-grad': 'var(--font-graduation-grad)',
};
export function fontFamilyForTheme(themeId: string) {

54
src/lib/ics.test.ts Normal file
View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { buildIcs, googleCalendarUrl, mapsUrl } from '@/lib/ics';
const base = {
uid: 'evt123@e-invite',
title: 'Backyard BBQ',
description: 'Bring a side dish',
location: '123 Main St',
start: new Date('2027-07-18T16:00:00Z'),
end: null as Date | null,
url: 'https://example.com/invite/abc123',
};
describe('buildIcs', () => {
it('includes required VEVENT fields with UTC-formatted dates', () => {
const ics = buildIcs(base);
expect(ics).toContain('BEGIN:VEVENT');
expect(ics).toContain('UID:evt123@e-invite');
expect(ics).toContain('DTSTART:20270718T160000Z');
expect(ics).toContain('SUMMARY:Backyard BBQ');
expect(ics).toContain('LOCATION:123 Main St');
expect(ics).toContain('END:VEVENT');
});
it('defaults to a 2-hour duration when no end time is given', () => {
const ics = buildIcs(base);
expect(ics).toContain('DTEND:20270718T180000Z');
});
it('uses the real end time when provided', () => {
const ics = buildIcs({ ...base, end: new Date('2027-07-18T20:00:00Z') });
expect(ics).toContain('DTEND:20270718T200000Z');
});
it('escapes commas, semicolons, and newlines in text fields', () => {
const ics = buildIcs({ ...base, description: 'Line one\nBring, chips; snacks' });
expect(ics).toContain('DESCRIPTION:Line one\\nBring\\, chips\\; snacks');
});
});
describe('googleCalendarUrl', () => {
it('builds a render URL with the event details and UTC date range', () => {
const url = googleCalendarUrl(base);
expect(url).toContain('https://calendar.google.com/calendar/render?');
expect(url).toContain('dates=20270718T160000Z%2F20270718T180000Z');
expect(url).toContain('text=Backyard+BBQ');
});
});
describe('mapsUrl', () => {
it('builds a Google Maps search URL for the location', () => {
expect(mapsUrl('123 Main St')).toBe('https://www.google.com/maps/search/?api=1&query=123+Main+St');
});
});

84
src/lib/ics.ts Normal file
View File

@ -0,0 +1,84 @@
/**
* Minimal iCalendar (RFC 5545) VEVENT generation and Google Calendar
* "render" link building, both driven off the same UTC instant fields
* (`eventDate`/`endDate`) Prisma stores - the event's `timezone` field is
* purely for *display* (see lib/format.ts), so it's irrelevant here: an
* absolute instant expressed in UTC renders correctly in any calendar
* client regardless of the viewer's zone.
*/
const DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours, when no end time was set
type IcsEvent = {
uid: string;
title: string;
description: string;
location: string;
start: Date;
end: Date | null;
url: string;
};
function icsDateUtc(date: Date): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
// RFC 5545 TEXT escaping: backslash, semicolon, comma, then newlines.
function escapeIcsText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n');
}
// Lines over 75 octets should be folded per RFC 5545; most clients tolerate
// long lines, but folding costs little and avoids surprises with picky ones.
function foldLine(line: string): string {
if (line.length <= 75) return line;
let out = line.slice(0, 75);
let rest = line.slice(75);
while (rest.length > 0) {
out += '\r\n ' + rest.slice(0, 74);
rest = rest.slice(74);
}
return out;
}
export function buildIcs(event: IcsEvent): string {
const end = event.end ?? new Date(event.start.getTime() + DEFAULT_DURATION_MS);
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//E-Invite//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${event.uid}`,
`DTSTAMP:${icsDateUtc(new Date())}`,
`DTSTART:${icsDateUtc(event.start)}`,
`DTEND:${icsDateUtc(end)}`,
`SUMMARY:${escapeIcsText(event.title)}`,
];
if (event.description) lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`);
if (event.location) lines.push(`LOCATION:${escapeIcsText(event.location)}`);
lines.push(`URL:${escapeIcsText(event.url)}`, 'END:VEVENT', 'END:VCALENDAR');
return lines.map(foldLine).join('\r\n') + '\r\n';
}
export function googleCalendarUrl(event: IcsEvent): string {
const end = event.end ?? new Date(event.start.getTime() + DEFAULT_DURATION_MS);
const params = new URLSearchParams({
action: 'TEMPLATE',
text: event.title,
dates: `${icsDateUtc(event.start)}/${icsDateUtc(end)}`,
details: event.description,
location: event.location,
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
export function mapsUrl(location: string): string {
return `https://www.google.com/maps/search/?${new URLSearchParams({ api: '1', query: location }).toString()}`;
}

View File

@ -210,6 +210,34 @@ export async function sendCollaboratorInviteEmail(opts: {
});
}
export async function sendThankYouEmail(opts: {
to: string;
guestName?: string;
hostName: string;
eventTitle: string;
message: string;
inviteUrl: string;
}): Promise<boolean> {
const guestName = opts.guestName ? escapeHtml(stripNewlines(opts.guestName)) : '';
const hostName = escapeHtml(stripNewlines(opts.hostName));
const eventTitle = escapeHtml(opts.eventTitle);
const greeting = guestName ? `Hi ${guestName},` : 'Hi,';
const message = escapeHtml(opts.message).replace(/\r?\n/g, '<br>');
return sendMail({
to: opts.to,
subject: `Thank you from ${stripNewlines(opts.hostName)} - ${opts.eventTitle}`,
html: layout(
`Thanks for being part of ${eventTitle}!`,
`
<p>${greeting}</p>
<p>${message}</p>
<p style="margin-top:20px;">- ${hostName}</p>
<p><a href="${opts.inviteUrl}">View the invitation again</a></p>
`
),
});
}
export async function sendVerificationEmail(opts: { to: string; verifyUrl: string }): Promise<boolean> {
return sendMail({
to: opts.to,

View File

@ -2,6 +2,8 @@
// here is generated as a data: URI so there are no external image assets to
// ship - the Docker image stays fully self-contained.
export type Pattern = { backgroundImage: string; backgroundSize: string };
function svgBackground(svg: string, size: string) {
const uri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
return { backgroundImage: uri, backgroundSize: size };
@ -48,52 +50,71 @@ export function polkaDotPattern(color: string, radius = 2.5) {
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered leaf/vine motifs for a garden theme. */
/** Scattered pointed-oval leaves (with a center vein) alternating between
* two colors, for a garden theme. */
export function leafPattern(leaf: string, accent: string) {
const size = 150;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g opacity="0.5" fill="none" stroke="${leaf}" stroke-width="2">
<path d="M20 30 Q30 15 45 25 Q30 35 20 30 Z" fill="${leaf}" stroke="none"/>
<path d="M100 20 Q108 8 120 15 Q110 25 100 20 Z" fill="${accent}" stroke="none"/>
<path d="M70 70 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
<path d="M20 100 Q30 85 45 95 Q30 105 20 100 Z" fill="${accent}" stroke="none"/>
<path d="M110 110 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
<path d="M60 10 C 60 40, 60 40, 60 55" stroke="${leaf}" stroke-width="1.5"/>
</g>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered 6-point snowflakes for a winter/holiday theme. */
export function snowflakePattern(color: string) {
const size = 120;
const flake = (cx: number, cy: number, r: number) => `
<g transform="translate(${cx} ${cy})" stroke="${color}" stroke-width="1.4" opacity="0.6">
${[0, 60, 120].map((deg) => `<line x1="${-r}" y1="0" x2="${r}" y2="0" transform="rotate(${deg})"/>`).join('')}
const leafShape = (cx: number, cy: number, rotate: number, scale: number, color: string) => `
<g transform="translate(${cx} ${cy}) rotate(${rotate}) scale(${scale})" opacity="0.55">
<path d="M0 -12 C 8 -8, 8 8, 0 12 C -8 8, -8 -8, 0 -12 Z" fill="${color}"/>
<line x1="0" y1="-10" x2="0" y2="10" stroke="${color}" stroke-width="0.6" opacity="0.6"/>
</g>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
${flake(20, 25, 7)}
${flake(85, 15, 5)}
${flake(60, 70, 9)}
${flake(105, 95, 6)}
${flake(15, 95, 5)}
${leafShape(24, 30, -25, 1, leaf)}
${leafShape(112, 22, 40, 0.8, accent)}
${leafShape(78, 78, 100, 0.9, leaf)}
${leafShape(28, 108, -50, 0.8, accent)}
${leafShape(120, 118, 15, 1, leaf)}
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered soft 4-point sparkle stars for a pastel theme. */
/** Scattered 6-branch snowflakes, each arm carrying a pair of side ticks so
* they read as snowflakes rather than plain asterisks, for a winter/holiday
* theme. */
export function snowflakePattern(color: string) {
const size = 120;
const flake = (cx: number, cy: number, r: number) => {
const arm = (deg: number) => `
<g transform="rotate(${deg})">
<line x1="0" y1="0" x2="${r}" y2="0"/>
<line x1="${r * 0.55}" y1="0" x2="${r * 0.38}" y2="${-r * 0.22}"/>
<line x1="${r * 0.55}" y1="0" x2="${r * 0.38}" y2="${r * 0.22}"/>
</g>`;
return `
<g transform="translate(${cx} ${cy})" stroke="${color}" stroke-width="1.1" stroke-linecap="round" opacity="0.65">
${[0, 60, 120, 180, 240, 300].map(arm).join('')}
<circle cx="0" cy="0" r="1.1" fill="${color}" stroke="none"/>
</g>`;
};
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
${flake(20, 25, 8)}
${flake(85, 15, 6)}
${flake(60, 70, 10)}
${flake(105, 95, 7)}
${flake(15, 95, 6)}
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Soft 4-point sparkle stars mixed with small twinkle dots, for a pastel
* theme. */
export function starPattern(colors: string[]) {
const size = 130;
const [a, b] = colors;
const star = (cx: number, cy: number, r: number, fill: string) =>
const sparkle = (cx: number, cy: number, r: number, fill: string) =>
`<path d="M${cx} ${cy - r} Q${cx} ${cy} ${cx + r} ${cy} Q${cx} ${cy} ${cx} ${cy + r} Q${cx} ${cy} ${cx - r} ${cy} Q${cx} ${cy} ${cx} ${cy - r} Z" fill="${fill}"/>`;
const dot = (cx: number, cy: number, r: number, fill: string) => `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"/>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g opacity="0.6">
${star(25, 30, 7, a)}
${star(95, 20, 5, b)}
${star(60, 65, 6, a)}
${star(110, 90, 8, b)}
${star(20, 100, 5, a)}
<g opacity="0.65">
${sparkle(25, 30, 8, a)}
${sparkle(95, 20, 6, b)}
${sparkle(60, 65, 7, a)}
${sparkle(110, 90, 9, b)}
${sparkle(20, 100, 6, a)}
${dot(70, 15, 2, b)}
${dot(35, 78, 2, a)}
${dot(100, 55, 2, b)}
</g>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
@ -116,14 +137,133 @@ export function scratchPattern(color: string) {
return svgBackground(svg, `${size}px ${size}px`);
}
/** Subtle pixel-grid with scattered neon "pixels" for a retro arcade theme. */
export function pixelGridPattern(gridColor: string, pixelColors: string[]) {
const size = 40;
const [a, b] = pixelColors;
/** Delicate five-petal florets (rounded petals, not just dots) scattered on
* their own, for a wedding/romance theme - restrained and lace-like rather
* than busy. */
export function floralLacePattern(color: string) {
const size = 100;
const flower = (cx: number, cy: number, r: number) => {
const petal = (deg: number) =>
`<ellipse cx="${cx}" cy="${cy - r}" rx="${r * 0.42}" ry="${r * 0.62}" fill="${color}" transform="rotate(${deg} ${cx} ${cy})"/>`;
return `
<g opacity="0.35">
${[0, 72, 144, 216, 288].map(petal).join('')}
<circle cx="${cx}" cy="${cy}" r="${r * 0.3}" fill="${color}"/>
</g>`;
};
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<rect width="${size}" height="${size}" fill="none" stroke="${gridColor}" stroke-width="1" opacity="0.25"/>
<rect x="4" y="4" width="5" height="5" fill="${a}" opacity="0.6"/>
<rect x="26" y="20" width="5" height="5" fill="${b}" opacity="0.6"/>
${flower(20, 20, 7)}
${flower(78, 55, 5)}
${flower(45, 85, 7)}
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Fanned palm fronds with a small hibiscus accent, for a tropical/luau theme. */
export function palmLeafPattern(leafColor: string, flowerColor: string) {
const size = 140;
const frond = (cx: number, cy: number, scale: number, rotate: number) => `
<g transform="translate(${cx} ${cy}) rotate(${rotate}) scale(${scale})" fill="${leafColor}" opacity="0.4">
<path d="M0 0 Q-14 -6 -20 -22 Q-6 -14 0 0 Z"/>
<path d="M0 0 Q4 -16 -4 -30 Q10 -20 0 0 Z"/>
<path d="M0 0 Q16 -8 24 -22 Q8 -16 0 0 Z"/>
</g>`;
const flower = (cx: number, cy: number) => `
<g transform="translate(${cx} ${cy})" opacity="0.55">
${[0, 72, 144, 216, 288]
.map((deg) => `<ellipse cx="0" cy="-6" rx="2.5" ry="6" fill="${flowerColor}" transform="rotate(${deg})"/>`)
.join('')}
<circle r="2" fill="${flowerColor}"/>
</g>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
${frond(24, 110, 1, -20)}
${frond(110, 30, 0.8, 160)}
${flower(95, 100)}
${flower(30, 25)}
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Wheat spikes - kernels spaced in a clean herringbone up each stem rather
* than overlapping - plus a couple of falling leaves, for a rustic/harvest
* theme. */
export function wheatPattern(color: string) {
const size = 130;
const stalk = (baseX: number, baseY: number, height: number, rows: number) => {
const kernel = (x: number, y: number, angle: number) =>
`<ellipse cx="${x}" cy="${y}" rx="2" ry="4.2" fill="${color}" transform="rotate(${angle} ${x} ${y})"/>`;
const kernels = Array.from({ length: rows }, (_, i) => {
const y = baseY - (i / (rows - 1)) * height;
return `${kernel(baseX - 3.5, y, -30)}${kernel(baseX + 3.5, y, 30)}`;
}).join('');
return `
<line x1="${baseX}" y1="${baseY}" x2="${baseX}" y2="${baseY - height}" stroke="${color}" stroke-width="1"/>
${kernels}`;
};
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g opacity="0.45">
${stalk(20, 58, 42, 6)}
${stalk(98, 44, 36, 5)}
<path d="M95 90 q10 -14 24 -10 q-8 14 -24 10 Z" fill="${color}"/>
<path d="M18 100 q8 -10 18 -6 q-6 10 -18 6 Z" fill="${color}"/>
</g>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** Scattered flat-silhouette mortarboard caps - diamond board over a
* rounded band, with a button and a swinging tassel cord - for a graduation
* theme. */
export function gradCapPattern(color: string) {
const size = 110;
const cap = (cx: number, cy: number, scale: number) => `
<g transform="translate(${cx} ${cy}) scale(${scale})" opacity="0.5">
<ellipse cx="0" cy="7" rx="9" ry="5" fill="${color}"/>
<path d="M0 -14 L20 -2 L0 9 L-20 -2 Z" fill="${color}"/>
<circle cx="0" cy="-2" r="2" fill="${color}"/>
<path d="M0 -1 L14 7 L11 15" fill="none" stroke="${color}" stroke-width="1.6" stroke-linecap="round"/>
</g>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
${cap(28, 32, 0.85)}
${cap(85, 78, 0.6)}
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}
/** A bundled photographic texture (see public/textures/ - sourced from
* transparenttextures.com and shipped in the Docker image rather than
* fetched from that site at runtime, so a theme's background doesn't depend
* on an external host being reachable). Meant to be layered under a theme's
* accent-colored SVG motif via combinePatterns, not used alone - these are
* flat/monochrome and rely on the theme's bg color showing through. */
export function texturePattern(path: string, width: number, height: number): Pattern {
return { backgroundImage: `url("${path}")`, backgroundSize: `${width}px ${height}px` };
}
/** Layers multiple patterns into one CSS multi-background value - the first
* pattern paints on top, later ones behind it. */
export function combinePatterns(...patterns: Pattern[]): Pattern {
return {
backgroundImage: patterns.map((p) => p.backgroundImage).join(', '),
backgroundSize: patterns.map((p) => p.backgroundSize).join(', '),
};
}
/** Sparse neon "pixels" over a faint grid for a retro arcade theme. Sized
* and toned in line with the other patterns (large tile, lots of
* negative space, low opacity) rather than a dense wall-to-wall grid,
* which read as too busy behind a full page of text. */
export function pixelGridPattern(gridColor: string, pixelColors: string[]) {
const size = 120;
const [a, b] = pixelColors;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<g stroke="${gridColor}" stroke-width="1" opacity="0.12">
<line x1="0" y1="0.5" x2="${size}" y2="0.5"/>
<line x1="0.5" y1="0" x2="0.5" y2="${size}"/>
</g>
<rect x="16" y="22" width="6" height="6" fill="${a}" opacity="0.35"/>
<rect x="80" y="66" width="6" height="6" fill="${b}" opacity="0.35"/>
<rect x="52" y="92" width="4" height="4" fill="${a}" opacity="0.25"/>
</svg>`;
return svgBackground(svg, `${size}px ${size}px`);
}

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { resolvePhotoExtension } from '@/lib/photoStorage';
function fileWith(name: string, type: string): File {
return new File(['data'], name, { type });
}
describe('resolvePhotoExtension', () => {
it('resolves standard image types by MIME type', () => {
expect(resolvePhotoExtension(fileWith('photo.jpg', 'image/jpeg'))).toBe('jpg');
expect(resolvePhotoExtension(fileWith('photo.png', 'image/png'))).toBe('png');
expect(resolvePhotoExtension(fileWith('photo.webp', 'image/webp'))).toBe('webp');
expect(resolvePhotoExtension(fileWith('photo.gif', 'image/gif'))).toBe('gif');
});
it('resolves HEIC/HEIF by MIME type', () => {
expect(resolvePhotoExtension(fileWith('IMG_0001.heic', 'image/heic'))).toBe('heic');
expect(resolvePhotoExtension(fileWith('IMG_0001.heif', 'image/heif'))).toBe('heif');
});
it('falls back to the filename extension for HEIC/HEIF when the browser sends a generic MIME type', () => {
expect(resolvePhotoExtension(fileWith('IMG_0001.HEIC', 'application/octet-stream'))).toBe('heic');
expect(resolvePhotoExtension(fileWith('IMG_0001.heif', ''))).toBe('heif');
});
it('does not apply the generic-MIME fallback to non-HEIC extensions', () => {
expect(resolvePhotoExtension(fileWith('photo.exe', 'application/octet-stream'))).toBeNull();
});
it('rejects unsupported types', () => {
expect(resolvePhotoExtension(fileWith('doc.pdf', 'application/pdf'))).toBeNull();
expect(resolvePhotoExtension(fileWith('clip.mp4', 'video/mp4'))).toBeNull();
});
});

View File

@ -9,8 +9,15 @@ export const ALLOWED_PHOTO_TYPES: Record<string, string> = {
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/heic': 'heic',
'image/heif': 'heif',
};
// iOS (and some Android browsers) often report a generic or empty MIME type
// for HEIC/HEIF files instead of image/heic - fall back to the filename
// extension for just those formats when that happens.
const HEIC_EXTENSION_FALLBACK: Record<string, string> = { heic: 'heic', heif: 'heif' };
const UPLOADS_ROOT = path.join(process.cwd(), 'public', 'uploads');
function eventDir(eventId: string) {
@ -22,19 +29,51 @@ export function photoUrl(eventId: string, filename: string): string {
return `/uploads/${eventId}/${filename}`;
}
/** Writes an uploaded file to disk under a per-event directory and returns
* the generated filename (not the full path - callers store just this). */
export async function savePhotoFile(eventId: string, file: File): Promise<string> {
const ext = ALLOWED_PHOTO_TYPES[file.type];
if (!ext) {
throw new Error(`Unsupported file type: ${file.type}`);
}
/** URL to show in a gallery: the thumbnail if one was generated (HEIC/HEIF),
* otherwise the original file, which every supported format can render inline. */
export function photoDisplayUrl(eventId: string, photo: { filename: string; thumbnailFilename: string | null }): string {
return photoUrl(eventId, photo.thumbnailFilename ?? photo.filename);
}
/** Absolute on-disk path for a stored photo file. */
export function photoFilePath(eventId: string, filename: string): string {
return path.join(eventDir(eventId), filename);
}
/** Resolves the on-disk extension for an uploaded file, or null if it's not
* an allowed photo type. Trusts the declared MIME type first, then falls
* back to the filename extension for HEIC/HEIF (see HEIC_EXTENSION_FALLBACK). */
export function resolvePhotoExtension(file: File): string | null {
if (ALLOWED_PHOTO_TYPES[file.type]) return ALLOWED_PHOTO_TYPES[file.type];
if (!file.type || file.type === 'application/octet-stream') {
const nameExt = file.name.split('.').pop()?.toLowerCase();
if (nameExt && HEIC_EXTENSION_FALLBACK[nameExt]) return HEIC_EXTENSION_FALLBACK[nameExt];
}
return null;
}
export function isHeicExtension(ext: string): boolean {
return ext === 'heic' || ext === 'heif';
}
/** Writes a photo buffer to disk under a per-event directory and returns
* the generated filename (not the full path - callers store just this). */
export async function savePhotoFile(eventId: string, buffer: Buffer, ext: string): Promise<string> {
const dir = eventDir(eventId);
await mkdir(dir, { recursive: true });
const filename = `${randomBytes(12).toString('hex')}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(path.join(dir, filename), buffer);
return filename;
}
/** Writes a generated WebP thumbnail to disk alongside the original photo. */
export async function saveThumbnailFile(eventId: string, buffer: Buffer): Promise<string> {
const dir = eventDir(eventId);
await mkdir(dir, { recursive: true });
const filename = `${randomBytes(12).toString('hex')}.webp`;
await writeFile(path.join(dir, filename), buffer);
return filename;

15
src/lib/photoThumbnail.ts Normal file
View File

@ -0,0 +1,15 @@
import 'server-only';
import heicConvert from 'heic-convert';
import sharp from 'sharp';
const THUMBNAIL_MAX_DIMENSION = 1600;
/** Decodes a HEIC/HEIF buffer and re-encodes it as a resized WebP thumbnail,
* for browsers (i.e. everything but Safari) that can't render HEIC/HEIF inline. */
export async function convertHeicToWebpThumbnail(buffer: Buffer): Promise<Buffer> {
const jpeg = await heicConvert({ buffer, format: 'JPEG', quality: 0.9 });
return sharp(Buffer.from(jpeg))
.resize({ width: THUMBNAIL_MAX_DIMENSION, height: THUMBNAIL_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
}

38
src/lib/photoZip.ts Normal file
View File

@ -0,0 +1,38 @@
import 'server-only';
import { ZipArchive } from 'archiver';
import { Readable } from 'node:stream';
import { photoFilePath } from '@/lib/photoStorage';
type ZipPhoto = { eventId: string; filename: string; uploaderName: string; createdAt: Date };
function sanitizeForFilename(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
/** Streams the *original* photo files (never the display thumbnail) into a
* zip archive, oldest first. Each entry is named
* `{event}-{upload date}-{uploader}-{index}.ext` so files stay identifiable
* once extracted out of the zip; the index prefix guards against collisions
* when several guests share a name, upload on the same day, or leave the
* name blank. */
export function buildPhotoZipStream(eventSlug: string, photos: ZipPhoto[]): ReadableStream<Uint8Array> {
const archive = new ZipArchive({ zlib: { level: 6 } });
archive.on('warning', (err: Error) => console.error('Photo zip warning:', err));
archive.on('error', (err: Error) => console.error('Photo zip error:', err));
for (const [index, photo] of photos.entries()) {
const ext = photo.filename.split('.').pop() ?? 'jpg';
const date = photo.createdAt.toISOString().slice(0, 10);
const uploader = sanitizeForFilename(photo.uploaderName) || 'guest';
const entryName = `${eventSlug}-${date}-${uploader}-${String(index + 1).padStart(3, '0')}.${ext}`;
archive.file(photoFilePath(photo.eventId, photo.filename), { name: entryName });
}
archive.finalize();
return Readable.toWeb(archive) as ReadableStream<Uint8Array>;
}

View File

@ -7,35 +7,48 @@ import {
starPattern,
scratchPattern,
pixelGridPattern,
floralLacePattern,
palmLeafPattern,
wheatPattern,
gradCapPattern,
texturePattern,
combinePatterns,
type Pattern,
} from '@/lib/patterns';
import { deriveThemeColors, type ThemeColors } from '@/lib/color';
export type Theme = {
id: string;
name: string;
emoji: string;
colors: {
bg: string;
surface: string;
primary: string;
accent: string;
text: string;
};
pattern: {
backgroundImage: string;
backgroundSize: string;
};
/** Light card vs. dark card - drives how resolveTheme derives a full
* palette from a host's custom primary/accent choice (see color.ts). */
mode: 'light' | 'dark';
colors: ThemeColors;
pattern: Pattern;
/** Rebuilds this theme's pattern for an arbitrary color set - used by
* resolveTheme when a host overrides the default colors. */
buildPattern: (colors: ThemeColors) => Pattern;
};
type ThemeDefinition = Omit<Theme, 'pattern'>;
function defineTheme(def: ThemeDefinition): Theme {
return { ...def, pattern: def.buildPattern(def.colors) };
}
// Colors applied via CSS custom properties (see ThemedInviteLayout).
// Fonts are mapped separately in src/lib/fonts.ts since next/font classes
// must be static imports, not computed dynamically. `pattern` is a tileable
// inline-SVG background (see src/lib/patterns.ts) that gives each theme a
// distinct texture, not just a color/font swap.
// distinct texture, not just a color/font swap. `buildPattern` regenerates
// that same pattern for a different color set - see resolveTheme below.
export const THEMES: Theme[] = [
{
defineTheme({
id: 'classic-elegant',
name: 'Classic Elegant',
emoji: '🥂',
mode: 'light',
colors: {
bg: '#f5f1ea',
surface: '#ffffff',
@ -43,12 +56,13 @@ export const THEMES: Theme[] = [
accent: '#b08d57',
text: '#2b2118',
},
pattern: damaskPattern('#b08d57'),
},
{
buildPattern: (c) => combinePatterns(damaskPattern(c.accent), texturePattern('/textures/exclusive-paper.png', 560, 420)),
}),
defineTheme({
id: 'birthday-confetti',
name: 'Birthday Confetti',
emoji: '🎉',
mode: 'light',
colors: {
bg: '#fff3f8',
surface: '#ffffff',
@ -56,12 +70,13 @@ export const THEMES: Theme[] = [
accent: '#ffb703',
text: '#3a1030',
},
pattern: confettiPattern(['#e0218a', '#ffb703', '#3dd6c2']),
},
{
buildPattern: (c) => confettiPattern([c.primary, c.accent, '#3dd6c2']),
}),
defineTheme({
id: 'garden-party',
name: 'Garden Party',
emoji: '🌿',
mode: 'light',
colors: {
bg: '#f1f7ee',
surface: '#ffffff',
@ -69,12 +84,13 @@ export const THEMES: Theme[] = [
accent: '#e0a458',
text: '#233720',
},
pattern: leafPattern('#3f6b3a', '#e0a458'),
},
{
buildPattern: (c) => leafPattern(c.primary, c.accent),
}),
defineTheme({
id: 'modern-minimal',
name: 'Modern Minimal',
emoji: '⬛',
mode: 'light',
colors: {
bg: '#fafafa',
surface: '#ffffff',
@ -82,12 +98,13 @@ export const THEMES: Theme[] = [
accent: '#2563eb',
text: '#111827',
},
pattern: polkaDotPattern('#2563eb', 1.75),
},
{
buildPattern: (c) => polkaDotPattern(c.accent, 1.75),
}),
defineTheme({
id: 'holiday-festive',
name: 'Holiday Festive',
emoji: '❄️',
mode: 'dark',
colors: {
bg: '#0f2d24',
surface: '#153a2e',
@ -95,12 +112,13 @@ export const THEMES: Theme[] = [
accent: '#f1c40f',
text: '#fdf6ec',
},
pattern: snowflakePattern('#f1c40f'),
},
{
buildPattern: (c) => combinePatterns(snowflakePattern(c.accent), texturePattern('/textures/fresh-snow.png', 500, 500)),
}),
defineTheme({
id: 'baby-shower-pastel',
name: 'Baby Shower Pastel',
emoji: '🍼',
mode: 'light',
colors: {
bg: '#f4f1fb',
surface: '#ffffff',
@ -108,12 +126,13 @@ export const THEMES: Theme[] = [
accent: '#f6b6c1',
text: '#3a3350',
},
pattern: starPattern(['#8e7cc3', '#f6b6c1']),
},
{
buildPattern: (c) => starPattern([c.primary, c.accent]),
}),
defineTheme({
id: 'goth-grunge',
name: 'Goth Grunge',
emoji: '💀',
mode: 'dark',
colors: {
bg: '#121212',
surface: '#1c1c1c',
@ -121,12 +140,13 @@ export const THEMES: Theme[] = [
accent: '#9333ea',
text: '#e5e5e5',
},
pattern: scratchPattern('#9333ea'),
},
{
buildPattern: (c) => combinePatterns(scratchPattern(c.accent), texturePattern('/textures/dark-leather.png', 398, 484)),
}),
defineTheme({
id: 'retro-arcade',
name: 'Retro Arcade',
emoji: '🕹️',
mode: 'dark',
colors: {
bg: '#0d0221',
surface: '#1a0b3d',
@ -134,8 +154,64 @@ export const THEMES: Theme[] = [
accent: '#00e5ff',
text: '#eafffb',
},
pattern: pixelGridPattern('#00e5ff', ['#ff2e88', '#00e5ff']),
},
buildPattern: (c) => pixelGridPattern(c.accent, [c.primary, c.accent]),
}),
defineTheme({
id: 'wedding-romance',
name: 'Wedding Romance',
emoji: '💍',
mode: 'light',
colors: {
bg: '#fdf5f2',
surface: '#ffffff',
primary: '#8c5b6b',
accent: '#c9a45c',
text: '#3a2a2e',
},
buildPattern: (c) => combinePatterns(floralLacePattern(c.accent), texturePattern('/textures/white-linen.png', 400, 300)),
}),
defineTheme({
id: 'tropical-luau',
name: 'Tropical Luau',
emoji: '🌺',
mode: 'light',
colors: {
bg: '#e6fbf7',
surface: '#ffffff',
primary: '#0f766e',
accent: '#f59e0b',
text: '#0b3d3a',
},
buildPattern: (c) => palmLeafPattern(c.primary, c.accent),
}),
defineTheme({
id: 'rustic-harvest',
name: 'Rustic Harvest',
emoji: '🍂',
mode: 'light',
colors: {
bg: '#f7ecd9',
surface: '#fffaf1',
primary: '#7a4a24',
accent: '#c1622d',
text: '#3d2812',
},
buildPattern: (c) => combinePatterns(wheatPattern(c.accent), texturePattern('/textures/cardboard.png', 600, 600)),
}),
defineTheme({
id: 'graduation-grad',
name: 'Graduation',
emoji: '🎓',
mode: 'dark',
colors: {
bg: '#0b1f3a',
surface: '#122a4d',
primary: '#f2c14e',
accent: '#f2c14e',
text: '#f5f5f0',
},
buildPattern: (c) => gradCapPattern(c.accent),
}),
];
export const DEFAULT_THEME_ID = THEMES[0].id;
@ -143,3 +219,27 @@ export const DEFAULT_THEME_ID = THEMES[0].id;
export function getTheme(id: string): Theme {
return THEMES.find((t) => t.id === id) ?? THEMES[0];
}
/**
* Resolves a theme for rendering, with optional host-chosen primary/accent
* overrides (Event.themePrimaryColor / themeAccentColor). With no overrides,
* returns the theme's own hand-tuned default palette and pattern completely
* unchanged. With one or both overrides, derives a full contrast-safe
* palette (see deriveThemeColors) and rebuilds the pattern to match, so a
* customized theme never ends up illegible.
*/
export function resolveTheme(
id: string,
primaryOverride?: string | null,
accentOverride?: string | null
): Theme {
const base = getTheme(id);
if (!primaryOverride && !accentOverride) return base;
const colors = deriveThemeColors(
base.mode,
primaryOverride || base.colors.primary,
accentOverride || base.colors.accent
);
return { ...base, colors, pattern: base.buildPattern(colors) };
}

View File

@ -1,6 +1,7 @@
import { z } from 'zod';
import { THEMES } from '@/lib/themes';
import { isValidDatetimeLocal } from '@/lib/timezone';
import { isValidHexColor } from '@/lib/color';
const themeIds = THEMES.map((t) => t.id) as [string, ...string[]];
@ -11,6 +12,12 @@ const optionalDatetimeLocal = z
.optional()
.or(z.literal(''));
const optionalHexColor = z
.string()
.refine((v) => !v || isValidHexColor(v), 'Enter a valid color')
.optional()
.or(z.literal(''));
const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
@ -57,6 +64,8 @@ export const eventFormSchema = z
description: z.string().trim().max(5000).default(''),
contactInfo: z.string().trim().max(300).default(''),
theme: z.enum(themeIds),
themePrimaryColor: optionalHexColor,
themeAccentColor: optionalHexColor,
timezone: z.string().trim().min(1, 'Timezone is required'),
eventDate: datetimeLocal,
endDate: optionalDatetimeLocal,
@ -88,6 +97,21 @@ export const rsvpSchema = z.object({
note: z.string().trim().max(1000).default(''),
});
export const rsvpUpdateSchema = z.object({
guestName: z.string().trim().min(1, 'Name is required').max(150),
status: z.enum(['YES', 'NO', 'MAYBE']),
numGuests: z.coerce.number().int().min(1).max(50),
note: z.string().trim().max(1000).default(''),
});
export const manualRsvpSchema = z.object({
guestName: z.string().trim().min(1, 'Name is required').max(150),
contact: z.string().trim().min(1, 'Enter an email or phone number'),
status: z.enum(['YES', 'NO', 'MAYBE']),
numGuests: z.coerce.number().int().min(1).max(50),
note: z.string().trim().max(1000).default(''),
});
export const commentSchema = z.object({
eventId: z.string().min(1),
authorName: z.string().trim().min(1, 'Name is required').max(150),
@ -105,6 +129,11 @@ export const inviteCollaboratorSchema = z.object({
email: z.string().trim().email('Enter a valid email'),
});
export const sendThankYouSchema = z.object({
eventId: z.string().min(1),
message: z.string().trim().min(1, 'Enter a message').max(2000),
});
export const photoUploadSchema = z.object({
eventId: z.string().min(1),
uploaderName: z.string().trim().max(100).default(''),

9
src/types/heic-convert.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module 'heic-convert' {
interface ConvertOptions {
buffer: Buffer | ArrayBuffer | Uint8Array;
format: 'JPEG' | 'PNG';
quality?: number;
}
export default function convert(options: ConvertOptions): Promise<ArrayBuffer>;
}