Compare commits
No commits in common. "a5775930aac7c78f45bf507638f36168a699dcd0" and "2c78112a4f9d7b060a10c2f98c2f62e90244a24c" have entirely different histories.
a5775930aa
...
2c78112a4f
@ -6,11 +6,10 @@ services:
|
|||||||
# Baked into the client bundle at build time - see Dockerfile.
|
# Baked into the client bundle at build time - see Dockerfile.
|
||||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}
|
||||||
ports:
|
ports:
|
||||||
- "3101:3100"
|
- "3100:3000"
|
||||||
environment:
|
environment:
|
||||||
PORT: 3100
|
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql://einvite:einvite@postgres:5432/einvite?schema=public}
|
DATABASE_URL: ${DATABASE_URL:-postgresql://einvite:einvite@postgres:5432/einvite?schema=public}
|
||||||
APP_URL: ${APP_URL:-http://localhost:3101}
|
APP_URL: ${APP_URL:-http://localhost:3100}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-to-a-long-random-string}
|
SESSION_SECRET: ${SESSION_SECRET:-change-me-to-a-long-random-string}
|
||||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
SMTP_HOST: ${SMTP_HOST:-mailpit}
|
SMTP_HOST: ${SMTP_HOST:-mailpit}
|
||||||
@ -33,7 +32,7 @@ services:
|
|||||||
mailpit:
|
mailpit:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
@ -64,9 +63,3 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
uploads-data:
|
uploads-data:
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 10.89.0.0/24
|
|
||||||
|
|||||||
1523
package-lock.json
generated
1523
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -18,9 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
"archiver": "^8.0.0",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"heic-convert": "^2.1.0",
|
|
||||||
"libphonenumber-js": "^1.13.8",
|
"libphonenumber-js": "^1.13.8",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"next": "^16.2.10",
|
"next": "^16.2.10",
|
||||||
@ -28,13 +26,11 @@
|
|||||||
"prisma": "^5.20.0",
|
"prisma": "^5.20.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"sharp": "^0.35.3",
|
|
||||||
"twilio": "^6.0.2",
|
"twilio": "^6.0.2",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^8.0.0",
|
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^20.14.9",
|
||||||
"@types/nodemailer": "^7.0.2",
|
"@types/nodemailer": "^7.0.2",
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -71,8 +71,6 @@ model Event {
|
|||||||
description String @default("")
|
description String @default("")
|
||||||
contactInfo String @default("")
|
contactInfo String @default("")
|
||||||
theme String @default("classic-elegant")
|
theme String @default("classic-elegant")
|
||||||
themePrimaryColor String?
|
|
||||||
themeAccentColor String?
|
|
||||||
timezone String @default("UTC")
|
timezone String @default("UTC")
|
||||||
eventDate DateTime
|
eventDate DateTime
|
||||||
endDate DateTime?
|
endDate DateTime?
|
||||||
@ -101,15 +99,12 @@ model Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Photo {
|
model Photo {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
eventId String
|
eventId String
|
||||||
filename String
|
filename String
|
||||||
// WebP thumbnail filename for formats browsers can't render inline (HEIC/HEIF).
|
uploaderName String @default("")
|
||||||
// Null means `filename` itself is already displayable - use it directly.
|
hidden Boolean @default(false)
|
||||||
thumbnailFilename String?
|
createdAt DateTime @default(now())
|
||||||
uploaderName String @default("")
|
|
||||||
hidden Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 199 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 466 B |
@ -19,8 +19,6 @@ function parseEventForm(formData: FormData) {
|
|||||||
description: formData.get('description') ?? '',
|
description: formData.get('description') ?? '',
|
||||||
contactInfo: formData.get('contactInfo') ?? '',
|
contactInfo: formData.get('contactInfo') ?? '',
|
||||||
theme: formData.get('theme'),
|
theme: formData.get('theme'),
|
||||||
themePrimaryColor: formData.get('themePrimaryColor') ?? '',
|
|
||||||
themeAccentColor: formData.get('themeAccentColor') ?? '',
|
|
||||||
timezone: formData.get('timezone'),
|
timezone: formData.get('timezone'),
|
||||||
eventDate: formData.get('eventDate'),
|
eventDate: formData.get('eventDate'),
|
||||||
endDate: formData.get('endDate') ?? '',
|
endDate: formData.get('endDate') ?? '',
|
||||||
@ -61,8 +59,6 @@ export async function createEventAction(_prev: ActionState, formData: FormData):
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
contactInfo: data.contactInfo,
|
contactInfo: data.contactInfo,
|
||||||
theme: data.theme,
|
theme: data.theme,
|
||||||
themePrimaryColor: data.themePrimaryColor || null,
|
|
||||||
themeAccentColor: data.themeAccentColor || null,
|
|
||||||
timezone: tz,
|
timezone: tz,
|
||||||
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
|
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
|
||||||
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
|
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
|
||||||
@ -103,8 +99,6 @@ export async function updateEventAction(
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
contactInfo: data.contactInfo,
|
contactInfo: data.contactInfo,
|
||||||
theme: data.theme,
|
theme: data.theme,
|
||||||
themePrimaryColor: data.themePrimaryColor || null,
|
|
||||||
themeAccentColor: data.themeAccentColor || null,
|
|
||||||
timezone: tz,
|
timezone: tz,
|
||||||
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
|
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
|
||||||
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
|
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
|
||||||
|
|||||||
@ -4,15 +4,7 @@ import { revalidatePath } from 'next/cache';
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { requireEventAccess } from '@/lib/eventAccess';
|
import { requireEventAccess } from '@/lib/eventAccess';
|
||||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||||
import {
|
import { savePhotoFile, deletePhotoFile, MAX_PHOTO_BYTES, ALLOWED_PHOTO_TYPES } from '@/lib/photoStorage';
|
||||||
savePhotoFile,
|
|
||||||
saveThumbnailFile,
|
|
||||||
deletePhotoFile,
|
|
||||||
MAX_PHOTO_BYTES,
|
|
||||||
resolvePhotoExtension,
|
|
||||||
isHeicExtension,
|
|
||||||
} from '@/lib/photoStorage';
|
|
||||||
import { convertHeicToWebpThumbnail } from '@/lib/photoThumbnail';
|
|
||||||
import { photoUploadSchema } from '@/lib/validation';
|
import { photoUploadSchema } from '@/lib/validation';
|
||||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||||
|
|
||||||
@ -51,9 +43,8 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
|
|||||||
if (!(file instanceof File) || file.size === 0) {
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
return { error: 'Choose a photo to upload' };
|
return { error: 'Choose a photo to upload' };
|
||||||
}
|
}
|
||||||
const ext = resolvePhotoExtension(file);
|
if (!ALLOWED_PHOTO_TYPES[file.type]) {
|
||||||
if (!ext) {
|
return { error: 'Only JPEG, PNG, WebP, and GIF images are allowed' };
|
||||||
return { error: 'Only JPEG, PNG, WebP, GIF, HEIC, and HEIF images are allowed' };
|
|
||||||
}
|
}
|
||||||
if (file.size > MAX_PHOTO_BYTES) {
|
if (file.size > MAX_PHOTO_BYTES) {
|
||||||
return { error: 'Photo is too large (8MB max)' };
|
return { error: 'Photo is too large (8MB max)' };
|
||||||
@ -62,22 +53,7 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
|
|||||||
// Write the file first (cheap to redo/discard), then enforce the cap with
|
// 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
|
// 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.
|
// pass a stale count check and land past MAX_PHOTOS_PER_EVENT together.
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const filename = await savePhotoFile(eventId, file);
|
||||||
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 {
|
try {
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
@ -85,13 +61,12 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
|
|||||||
if (photoCount >= MAX_PHOTOS_PER_EVENT) {
|
if (photoCount >= MAX_PHOTOS_PER_EVENT) {
|
||||||
throw new PhotoLimitReachedError();
|
throw new PhotoLimitReachedError();
|
||||||
}
|
}
|
||||||
await tx.photo.create({ data: { eventId, filename, thumbnailFilename, uploaderName } });
|
await tx.photo.create({ data: { eventId, filename, uploaderName } });
|
||||||
},
|
},
|
||||||
{ isolationLevel: 'Serializable' }
|
{ isolationLevel: 'Serializable' }
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await deletePhotoFile(eventId, filename);
|
await deletePhotoFile(eventId, filename);
|
||||||
if (thumbnailFilename) await deletePhotoFile(eventId, thumbnailFilename);
|
|
||||||
if (err instanceof PhotoLimitReachedError) {
|
if (err instanceof PhotoLimitReachedError) {
|
||||||
return { error: 'This event has reached its photo limit' };
|
return { error: 'This event has reached its photo limit' };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { rsvpSchema, rsvpUpdateSchema, manualRsvpSchema } from '@/lib/validation';
|
import { rsvpSchema } from '@/lib/validation';
|
||||||
import { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail';
|
import { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail';
|
||||||
import { appUrl } from '@/lib/slug';
|
import { appUrl } from '@/lib/slug';
|
||||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||||
import { sendPushToUser } from '@/lib/push';
|
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 };
|
export type ActionState = { error?: string; success?: string };
|
||||||
|
|
||||||
@ -115,110 +111,3 @@ export async function submitRsvpAction(_prev: ActionState, formData: FormData):
|
|||||||
revalidatePath(`/invite/${event.slug}`);
|
revalidatePath(`/invite/${event.slug}`);
|
||||||
return { success: SUCCESS_MESSAGE };
|
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}`);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
'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(' ') };
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -15,8 +15,6 @@ export default async function EditEventPage({ params }: { params: Promise<{ id:
|
|||||||
description: event.description,
|
description: event.description,
|
||||||
contactInfo: event.contactInfo,
|
contactInfo: event.contactInfo,
|
||||||
theme: event.theme,
|
theme: event.theme,
|
||||||
themePrimaryColor: event.themePrimaryColor ?? '',
|
|
||||||
themeAccentColor: event.themeAccentColor ?? '',
|
|
||||||
timezone: tz,
|
timezone: tz,
|
||||||
eventDate: utcToZonedDatetimeLocal(event.eventDate, tz),
|
eventDate: utcToZonedDatetimeLocal(event.eventDate, tz),
|
||||||
endDate: event.endDate ? utcToZonedDatetimeLocal(event.endDate, tz) : '',
|
endDate: event.endDate ? utcToZonedDatetimeLocal(event.endDate, tz) : '',
|
||||||
|
|||||||
@ -4,23 +4,19 @@ import { prisma } from '@/lib/prisma';
|
|||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
import { accessibleEventWhere } from '@/lib/eventAccess';
|
import { accessibleEventWhere } from '@/lib/eventAccess';
|
||||||
import { appUrl } from '@/lib/slug';
|
import { appUrl } from '@/lib/slug';
|
||||||
import { resolveTheme } from '@/lib/themes';
|
import { getTheme } from '@/lib/themes';
|
||||||
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
|
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
|
||||||
import { ShareLinkBox } from '@/components/ShareLinkBox';
|
import { ShareLinkBox } from '@/components/ShareLinkBox';
|
||||||
import { SendInvitesForm } from '@/components/SendInvitesForm';
|
import { SendInvitesForm } from '@/components/SendInvitesForm';
|
||||||
import { InviteCollaboratorForm } from '@/components/InviteCollaboratorForm';
|
import { InviteCollaboratorForm } from '@/components/InviteCollaboratorForm';
|
||||||
import { regenerateSlugAction, deleteEventAction } from '@/app/actions/events';
|
import { regenerateSlugAction, deleteEventAction } from '@/app/actions/events';
|
||||||
import { sendInvitesAction, removeGuestFromListAction } from '@/app/actions/invites';
|
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 { toggleCommentHiddenAction, deleteCommentAction } from '@/app/actions/comments';
|
||||||
import { inviteCollaboratorAction, removeCollaboratorAction } from '@/app/actions/collaborators';
|
import { inviteCollaboratorAction, removeCollaboratorAction } from '@/app/actions/collaborators';
|
||||||
import { togglePhotoHiddenAction, deletePhotoAction, toggleForceCloseAction } from '@/app/actions/photos';
|
import { togglePhotoHiddenAction, deletePhotoAction, toggleForceCloseAction } from '@/app/actions/photos';
|
||||||
import { formatEventDateRange } from '@/lib/format';
|
import { formatEventDateRange } from '@/lib/format';
|
||||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||||
import { photoDisplayUrl } from '@/lib/photoStorage';
|
import { photoUrl } from '@/lib/photoStorage';
|
||||||
import { RsvpRow } from '@/components/RsvpRow';
|
|
||||||
import { AddRsvpForm } from '@/components/AddRsvpForm';
|
|
||||||
|
|
||||||
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@ -41,18 +37,16 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
|||||||
const isPrimaryHost = event.hostId === user.id;
|
const isPrimaryHost = event.hostId === user.id;
|
||||||
const photosOpen = isPhotoUploadOpen(event);
|
const photosOpen = isPhotoUploadOpen(event);
|
||||||
|
|
||||||
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
|
const theme = getTheme(event.theme);
|
||||||
const inviteUrl = appUrl(`/invite/${event.slug}`);
|
const inviteUrl = appUrl(`/invite/${event.slug}`);
|
||||||
const tally = {
|
const tally = {
|
||||||
YES: event.rsvps.filter((r) => r.status === 'YES').reduce((s, r) => s + r.numGuests, 0),
|
YES: event.rsvps.filter((r) => r.status === 'YES').reduce((s, r) => s + r.numGuests, 0),
|
||||||
NO: event.rsvps.filter((r) => r.status === 'NO').length,
|
NO: event.rsvps.filter((r) => r.status === 'NO').length,
|
||||||
MAYBE: event.rsvps.filter((r) => r.status === 'MAYBE').reduce((s, r) => s + r.numGuests, 0),
|
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 boundRegenerate = regenerateSlugAction.bind(null, event.id);
|
||||||
const boundSendInvites = sendInvitesAction.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 boundInviteCollaborator = inviteCollaboratorAction.bind(null, event.id);
|
||||||
const boundToggleForceClose = toggleForceCloseAction.bind(null, event.id);
|
const boundToggleForceClose = toggleForceCloseAction.bind(null, event.id);
|
||||||
|
|
||||||
@ -191,24 +185,14 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<h2 className="font-semibold text-gray-900">
|
||||||
<h2 className="font-semibold text-gray-900">
|
RSVPs
|
||||||
RSVPs
|
{event.restrictRsvpToInvited && (
|
||||||
{event.restrictRsvpToInvited && (
|
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
|
||||||
<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
|
||||||
Restricted to guest list
|
</span>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</h2>
|
||||||
<div className="mt-2 flex gap-6 text-sm">
|
<div className="mt-2 flex gap-6 text-sm">
|
||||||
<span className="text-green-700">{tally.YES} attending</span>
|
<span className="text-green-700">{tally.YES} attending</span>
|
||||||
<span className="text-amber-700">{tally.MAYBE} maybe</span>
|
<span className="text-amber-700">{tally.MAYBE} maybe</span>
|
||||||
@ -224,34 +208,23 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
|||||||
<th className="py-2 font-medium">Status</th>
|
<th className="py-2 font-medium">Status</th>
|
||||||
<th className="py-2 font-medium">Guests</th>
|
<th className="py-2 font-medium">Guests</th>
|
||||||
<th className="py-2 font-medium">Note</th>
|
<th className="py-2 font-medium">Note</th>
|
||||||
<th className="py-2 font-medium" />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{event.rsvps.map((rsvp) => (
|
{event.rsvps.map((rsvp) => (
|
||||||
<RsvpRow key={rsvp.id} rsvp={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>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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'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'd yes yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3">
|
|
||||||
<ThankYouForm action={boundSendThankYou} recipientCount={yesRsvpCount} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||||
@ -292,28 +265,18 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-gray-900">Photos</h2>
|
<h2 className="font-semibold text-gray-900">Photos</h2>
|
||||||
<div className="flex items-center gap-2">
|
{event.photosEnabled && (
|
||||||
{event.photos.length > 0 && (
|
<form action={boundToggleForceClose}>
|
||||||
<a
|
<button
|
||||||
href={`/api/events/${event.id}/photos/zip`}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
Download all ({event.photos.length})
|
{event.photosClosed ? 'Reopen uploads' : 'Close uploads now'}
|
||||||
</a>
|
</button>
|
||||||
)}
|
</form>
|
||||||
{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>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
{!event.photosEnabled
|
{!event.photosEnabled
|
||||||
@ -334,16 +297,11 @@ 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' : ''}`}>
|
<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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={photoDisplayUrl(event.id, photo)}
|
src={photoUrl(event.id, photo.filename)}
|
||||||
alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'}
|
alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
loading="lazy"
|
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">
|
<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)}>
|
<form action={togglePhotoHiddenAction.bind(null, photo.id)}>
|
||||||
<button type="submit" className="hover:underline">
|
<button type="submit" className="hover:underline">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
import { resolveTheme } from '@/lib/themes';
|
import { getTheme } from '@/lib/themes';
|
||||||
import { PageContainer } from '@/components/PageContainer';
|
import { PageContainer } from '@/components/PageContainer';
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
@ -41,7 +41,7 @@ export default async function DashboardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ul className="mt-6 flex flex-col gap-3">
|
<ul className="mt-6 flex flex-col gap-3">
|
||||||
{events.map((event) => {
|
{events.map((event) => {
|
||||||
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
|
const theme = getTheme(event.theme);
|
||||||
const yesCount = event.rsvps
|
const yesCount = event.rsvps
|
||||||
.filter((r) => r.status === 'YES')
|
.filter((r) => r.status === 'YES')
|
||||||
.reduce((sum, r) => sum + r.numGuests, 0);
|
.reduce((sum, r) => sum + r.numGuests, 0);
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
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"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { resolveTheme } from '@/lib/themes';
|
import { getTheme } from '@/lib/themes';
|
||||||
import { ThemedInviteLayout } from '@/components/ThemedInviteLayout';
|
import { ThemedInviteLayout } from '@/components/ThemedInviteLayout';
|
||||||
import { ThemeDivider } from '@/components/ThemeDivider';
|
import { ThemeDivider } from '@/components/ThemeDivider';
|
||||||
import { themeCardStyle as cardStyle } from '@/components/ThemeBackground';
|
import { themeCardStyle as cardStyle } from '@/components/ThemeBackground';
|
||||||
@ -10,10 +10,7 @@ import { PhotoUploadForm } from '@/components/PhotoUploadForm';
|
|||||||
import { PhotoGallery } from '@/components/PhotoGallery';
|
import { PhotoGallery } from '@/components/PhotoGallery';
|
||||||
import { formatEventDateRange } from '@/lib/format';
|
import { formatEventDateRange } from '@/lib/format';
|
||||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||||
import { photoDisplayUrl } from '@/lib/photoStorage';
|
import { photoUrl } 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 }> }) {
|
export default async function PublicInvitePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
@ -28,7 +25,7 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
|
|||||||
});
|
});
|
||||||
if (!event) notFound();
|
if (!event) notFound();
|
||||||
|
|
||||||
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
|
const theme = getTheme(event.theme);
|
||||||
const rsvpClosed = Boolean(event.rsvpDeadline && event.rsvpDeadline < new Date());
|
const rsvpClosed = Boolean(event.rsvpDeadline && event.rsvpDeadline < new Date());
|
||||||
const photosOpen = isPhotoUploadOpen(event);
|
const photosOpen = isPhotoUploadOpen(event);
|
||||||
const showPhotos = event.photosEnabled && (photosOpen || event.photos.length > 0);
|
const showPhotos = event.photosEnabled && (photosOpen || event.photos.length > 0);
|
||||||
@ -46,40 +43,7 @@ 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>
|
<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>}
|
{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>
|
<p className="mt-3 text-lg">{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}</p>
|
||||||
<EventCountdown eventDate={event.eventDate.toISOString()} endDate={event.endDate?.toISOString() ?? null} />
|
{event.location && <p className="mt-1 opacity-80">{event.location}</p>}
|
||||||
{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) && (
|
{(event.foodProvided || event.drinksProvided) && (
|
||||||
<div className="mt-3 flex flex-wrap justify-center gap-2">
|
<div className="mt-3 flex flex-wrap justify-center gap-2">
|
||||||
{event.foodProvided && (
|
{event.foodProvided && (
|
||||||
@ -154,17 +118,7 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
|
|||||||
<>
|
<>
|
||||||
<ThemeDivider theme={theme} />
|
<ThemeDivider theme={theme} />
|
||||||
<div className="reveal rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.4s' }}>
|
<div className="reveal rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.4s' }}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<h2 className="text-xl font-semibold">Photos</h2>
|
||||||
<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 ? (
|
{photosOpen ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<PhotoUploadForm eventId={event.id} />
|
<PhotoUploadForm eventId={event.id} />
|
||||||
@ -175,7 +129,7 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
|
|||||||
<PhotoGallery
|
<PhotoGallery
|
||||||
photos={event.photos.map((p) => ({
|
photos={event.photos.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
url: photoDisplayUrl(event.id, p),
|
url: photoUrl(event.id, p.filename),
|
||||||
uploaderName: p.uploaderName,
|
uploaderName: p.uploaderName,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
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"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
'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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
'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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { resolveTheme, DEFAULT_THEME_ID } from '@/lib/themes';
|
import { getTheme, DEFAULT_THEME_ID } from '@/lib/themes';
|
||||||
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
|
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
|
||||||
import { EventForm, type EventActionState, type EventFormDefaults } from '@/components/EventForm';
|
import { EventForm, type EventActionState, type EventFormDefaults } from '@/components/EventForm';
|
||||||
|
|
||||||
@ -17,25 +17,14 @@ export function EventEditor({
|
|||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const [themeId, setThemeId] = useState(defaults?.theme ?? DEFAULT_THEME_ID);
|
const [themeId, setThemeId] = useState(defaults?.theme ?? DEFAULT_THEME_ID);
|
||||||
const [primaryColor, setPrimaryColor] = useState<string | null>(defaults?.themePrimaryColor || null);
|
const theme = getTheme(themeId);
|
||||||
const [accentColor, setAccentColor] = useState<string | null>(defaults?.themeAccentColor || null);
|
|
||||||
const theme = resolveTheme(themeId, primaryColor, accentColor);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeBackground theme={theme} applyTextStyle={false} className="flex-1 p-6 sm:p-10">
|
<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}>
|
<div className="mx-auto max-w-2xl rounded-lg p-6" style={adminCardStyle}>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<EventForm
|
<EventForm action={action} defaults={defaults} submitLabel={submitLabel} onThemeChange={setThemeId} />
|
||||||
action={action}
|
|
||||||
defaults={defaults}
|
|
||||||
submitLabel={submitLabel}
|
|
||||||
onThemeChange={setThemeId}
|
|
||||||
onColorsChange={(primary, accent) => {
|
|
||||||
setPrimaryColor(primary);
|
|
||||||
setAccentColor(accent);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ThemeBackground>
|
</ThemeBackground>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useActionState, useEffect, useState } from 'react';
|
import { useActionState, useEffect, useState } from 'react';
|
||||||
import { ThemePicker } from '@/components/ThemePicker';
|
import { ThemePicker } from '@/components/ThemePicker';
|
||||||
import { SubmitButton } from '@/components/SubmitButton';
|
import { SubmitButton } from '@/components/SubmitButton';
|
||||||
import { getTheme, DEFAULT_THEME_ID } from '@/lib/themes';
|
import { DEFAULT_THEME_ID } from '@/lib/themes';
|
||||||
import { TIMEZONES } from '@/lib/timezone';
|
import { TIMEZONES } from '@/lib/timezone';
|
||||||
|
|
||||||
export type EventActionState = { error?: string };
|
export type EventActionState = { error?: string };
|
||||||
@ -14,9 +14,6 @@ export type EventFormDefaults = {
|
|||||||
description: string;
|
description: string;
|
||||||
contactInfo: string;
|
contactInfo: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
/** '' means "use the theme's own default color". */
|
|
||||||
themePrimaryColor: string;
|
|
||||||
themeAccentColor: string;
|
|
||||||
timezone: string;
|
timezone: string;
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
@ -37,8 +34,6 @@ const emptyDefaults: EventFormDefaults = {
|
|||||||
description: '',
|
description: '',
|
||||||
contactInfo: '',
|
contactInfo: '',
|
||||||
theme: DEFAULT_THEME_ID,
|
theme: DEFAULT_THEME_ID,
|
||||||
themePrimaryColor: '',
|
|
||||||
themeAccentColor: '',
|
|
||||||
timezone: '',
|
timezone: '',
|
||||||
eventDate: '',
|
eventDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
@ -58,13 +53,11 @@ export function EventForm({
|
|||||||
defaults = emptyDefaults,
|
defaults = emptyDefaults,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
onColorsChange,
|
|
||||||
}: {
|
}: {
|
||||||
action: (state: EventActionState, formData: FormData) => Promise<EventActionState>;
|
action: (state: EventActionState, formData: FormData) => Promise<EventActionState>;
|
||||||
defaults?: EventFormDefaults;
|
defaults?: EventFormDefaults;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
onThemeChange?: (themeId: string) => void;
|
onThemeChange?: (themeId: string) => void;
|
||||||
onColorsChange?: (primary: string | null, accent: string | null) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [state, formAction] = useActionState(action, {});
|
const [state, formAction] = useActionState(action, {});
|
||||||
// New events (empty default) auto-detect the browser's zone; edits keep
|
// New events (empty default) auto-detect the browser's zone; edits keep
|
||||||
@ -76,32 +69,6 @@ export function EventForm({
|
|||||||
}
|
}
|
||||||
}, [defaults.timezone]);
|
}, [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 (
|
return (
|
||||||
<form action={formAction} className="flex flex-col gap-6">
|
<form action={formAction} className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
@ -257,57 +224,10 @@ export function EventForm({
|
|||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-700">Theme</span>
|
<span className="block text-sm font-medium text-gray-700">Theme</span>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ThemePicker defaultThemeId={defaults.theme} onChange={handleThemeChange} />
|
<ThemePicker defaultThemeId={defaults.theme} onChange={onThemeChange} />
|
||||||
</div>
|
</div>
|
||||||
</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's own colors, e.g. if the default palette doesn't
|
|
||||||
fit your event. The background and text stay auto-adjusted for readability.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -17,8 +17,7 @@ export function PhotoGallery({ photos }: { photos: GalleryPhoto[] }) {
|
|||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* 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" />
|
<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 && (
|
{photo.uploaderName && (
|
||||||
// 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 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-xs text-white">
|
|
||||||
{photo.uploaderName}
|
{photo.uploaderName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function PhotoUploadForm({ eventId }: { eventId: string }) {
|
|||||||
id="photo"
|
id="photo"
|
||||||
name="photo"
|
name="photo"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,image/heif,.heic,.heif"
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
required
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
'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'd yes
|
|
||||||
</SubmitButton>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
// 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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -5,11 +5,7 @@ import {
|
|||||||
Inter,
|
Inter,
|
||||||
Mountains_of_Christmas,
|
Mountains_of_Christmas,
|
||||||
Special_Elite,
|
Special_Elite,
|
||||||
VT323,
|
Press_Start_2P,
|
||||||
Cormorant_Garamond,
|
|
||||||
Fredoka,
|
|
||||||
Zilla_Slab,
|
|
||||||
Montserrat,
|
|
||||||
} from 'next/font/google';
|
} from 'next/font/google';
|
||||||
|
|
||||||
const playfairDisplay = Playfair_Display({ subsets: ['latin'], variable: '--font-classic-elegant' });
|
const playfairDisplay = Playfair_Display({ subsets: ['latin'], variable: '--font-classic-elegant' });
|
||||||
@ -23,19 +19,7 @@ const mountainsOfChristmas = Mountains_of_Christmas({
|
|||||||
});
|
});
|
||||||
const quicksandBaby = Quicksand({ subsets: ['latin'], variable: '--font-baby-shower-pastel' });
|
const quicksandBaby = Quicksand({ subsets: ['latin'], variable: '--font-baby-shower-pastel' });
|
||||||
const specialElite = Special_Elite({ subsets: ['latin'], weight: '400', variable: '--font-goth-grunge' });
|
const specialElite = Special_Elite({ subsets: ['latin'], weight: '400', variable: '--font-goth-grunge' });
|
||||||
// Press Start 2P (an actual pixel-block font) reads fine for a short
|
const pressStart2p = Press_Start_2P({ subsets: ['latin'], weight: '400', variable: '--font-retro-arcade' });
|
||||||
// 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
|
// 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
|
// next/font's generated CSS variables to be available, so we export one
|
||||||
@ -48,11 +32,7 @@ export const allFontVariables = [
|
|||||||
mountainsOfChristmas.variable,
|
mountainsOfChristmas.variable,
|
||||||
quicksandBaby.variable,
|
quicksandBaby.variable,
|
||||||
specialElite.variable,
|
specialElite.variable,
|
||||||
vt323.variable,
|
pressStart2p.variable,
|
||||||
cormorantGaramond.variable,
|
|
||||||
fredoka.variable,
|
|
||||||
zillaSlab.variable,
|
|
||||||
montserrat.variable,
|
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
const FONT_FAMILY_BY_THEME: Record<string, string> = {
|
const FONT_FAMILY_BY_THEME: Record<string, string> = {
|
||||||
@ -64,10 +44,6 @@ const FONT_FAMILY_BY_THEME: Record<string, string> = {
|
|||||||
'baby-shower-pastel': 'var(--font-baby-shower-pastel)',
|
'baby-shower-pastel': 'var(--font-baby-shower-pastel)',
|
||||||
'goth-grunge': 'var(--font-goth-grunge)',
|
'goth-grunge': 'var(--font-goth-grunge)',
|
||||||
'retro-arcade': 'var(--font-retro-arcade)',
|
'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) {
|
export function fontFamilyForTheme(themeId: string) {
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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()}`;
|
|
||||||
}
|
|
||||||
@ -210,34 +210,6 @@ 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> {
|
export async function sendVerificationEmail(opts: { to: string; verifyUrl: string }): Promise<boolean> {
|
||||||
return sendMail({
|
return sendMail({
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
// here is generated as a data: URI so there are no external image assets to
|
// here is generated as a data: URI so there are no external image assets to
|
||||||
// ship - the Docker image stays fully self-contained.
|
// ship - the Docker image stays fully self-contained.
|
||||||
|
|
||||||
export type Pattern = { backgroundImage: string; backgroundSize: string };
|
|
||||||
|
|
||||||
function svgBackground(svg: string, size: string) {
|
function svgBackground(svg: string, size: string) {
|
||||||
const uri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
const uri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
return { backgroundImage: uri, backgroundSize: size };
|
return { backgroundImage: uri, backgroundSize: size };
|
||||||
@ -50,71 +48,52 @@ export function polkaDotPattern(color: string, radius = 2.5) {
|
|||||||
return svgBackground(svg, `${size}px ${size}px`);
|
return svgBackground(svg, `${size}px ${size}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scattered pointed-oval leaves (with a center vein) alternating between
|
/** Scattered leaf/vine motifs for a garden theme. */
|
||||||
* two colors, for a garden theme. */
|
|
||||||
export function leafPattern(leaf: string, accent: string) {
|
export function leafPattern(leaf: string, accent: string) {
|
||||||
const size = 150;
|
const size = 150;
|
||||||
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}">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||||
${leafShape(24, 30, -25, 1, leaf)}
|
<g opacity="0.5" fill="none" stroke="${leaf}" stroke-width="2">
|
||||||
${leafShape(112, 22, 40, 0.8, accent)}
|
<path d="M20 30 Q30 15 45 25 Q30 35 20 30 Z" fill="${leaf}" stroke="none"/>
|
||||||
${leafShape(78, 78, 100, 0.9, leaf)}
|
<path d="M100 20 Q108 8 120 15 Q110 25 100 20 Z" fill="${accent}" stroke="none"/>
|
||||||
${leafShape(28, 108, -50, 0.8, accent)}
|
<path d="M70 70 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
|
||||||
${leafShape(120, 118, 15, 1, leaf)}
|
<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>`;
|
</svg>`;
|
||||||
return svgBackground(svg, `${size}px ${size}px`);
|
return svgBackground(svg, `${size}px ${size}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scattered 6-branch snowflakes, each arm carrying a pair of side ticks so
|
/** Scattered 6-point snowflakes for a winter/holiday theme. */
|
||||||
* they read as snowflakes rather than plain asterisks, for a winter/holiday
|
|
||||||
* theme. */
|
|
||||||
export function snowflakePattern(color: string) {
|
export function snowflakePattern(color: string) {
|
||||||
const size = 120;
|
const size = 120;
|
||||||
const flake = (cx: number, cy: number, r: number) => {
|
const flake = (cx: number, cy: number, r: number) => `
|
||||||
const arm = (deg: number) => `
|
<g transform="translate(${cx} ${cy})" stroke="${color}" stroke-width="1.4" opacity="0.6">
|
||||||
<g transform="rotate(${deg})">
|
${[0, 60, 120].map((deg) => `<line x1="${-r}" y1="0" x2="${r}" y2="0" transform="rotate(${deg})"/>`).join('')}
|
||||||
<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>`;
|
</g>`;
|
||||||
};
|
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||||
${flake(20, 25, 8)}
|
${flake(20, 25, 7)}
|
||||||
${flake(85, 15, 6)}
|
${flake(85, 15, 5)}
|
||||||
${flake(60, 70, 10)}
|
${flake(60, 70, 9)}
|
||||||
${flake(105, 95, 7)}
|
${flake(105, 95, 6)}
|
||||||
${flake(15, 95, 6)}
|
${flake(15, 95, 5)}
|
||||||
</svg>`;
|
</svg>`;
|
||||||
return svgBackground(svg, `${size}px ${size}px`);
|
return svgBackground(svg, `${size}px ${size}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft 4-point sparkle stars mixed with small twinkle dots, for a pastel
|
/** Scattered soft 4-point sparkle stars for a pastel theme. */
|
||||||
* theme. */
|
|
||||||
export function starPattern(colors: string[]) {
|
export function starPattern(colors: string[]) {
|
||||||
const size = 130;
|
const size = 130;
|
||||||
const [a, b] = colors;
|
const [a, b] = colors;
|
||||||
const sparkle = (cx: number, cy: number, r: number, fill: string) =>
|
const star = (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}"/>`;
|
`<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}">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||||
<g opacity="0.65">
|
<g opacity="0.6">
|
||||||
${sparkle(25, 30, 8, a)}
|
${star(25, 30, 7, a)}
|
||||||
${sparkle(95, 20, 6, b)}
|
${star(95, 20, 5, b)}
|
||||||
${sparkle(60, 65, 7, a)}
|
${star(60, 65, 6, a)}
|
||||||
${sparkle(110, 90, 9, b)}
|
${star(110, 90, 8, b)}
|
||||||
${sparkle(20, 100, 6, a)}
|
${star(20, 100, 5, a)}
|
||||||
${dot(70, 15, 2, b)}
|
|
||||||
${dot(35, 78, 2, a)}
|
|
||||||
${dot(100, 55, 2, b)}
|
|
||||||
</g>
|
</g>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
return svgBackground(svg, `${size}px ${size}px`);
|
return svgBackground(svg, `${size}px ${size}px`);
|
||||||
@ -137,133 +116,14 @@ export function scratchPattern(color: string) {
|
|||||||
return svgBackground(svg, `${size}px ${size}px`);
|
return svgBackground(svg, `${size}px ${size}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delicate five-petal florets (rounded petals, not just dots) scattered on
|
/** Subtle pixel-grid with scattered neon "pixels" for a retro arcade theme. */
|
||||||
* 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}">
|
|
||||||
${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[]) {
|
export function pixelGridPattern(gridColor: string, pixelColors: string[]) {
|
||||||
const size = 120;
|
const size = 40;
|
||||||
const [a, b] = pixelColors;
|
const [a, b] = pixelColors;
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||||
<g stroke="${gridColor}" stroke-width="1" opacity="0.12">
|
<rect width="${size}" height="${size}" fill="none" stroke="${gridColor}" stroke-width="1" opacity="0.25"/>
|
||||||
<line x1="0" y1="0.5" x2="${size}" y2="0.5"/>
|
<rect x="4" y="4" width="5" height="5" fill="${a}" opacity="0.6"/>
|
||||||
<line x1="0.5" y1="0" x2="0.5" y2="${size}"/>
|
<rect x="26" y="20" width="5" height="5" fill="${b}" opacity="0.6"/>
|
||||||
</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>`;
|
</svg>`;
|
||||||
return svgBackground(svg, `${size}px ${size}px`);
|
return svgBackground(svg, `${size}px ${size}px`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -9,15 +9,8 @@ export const ALLOWED_PHOTO_TYPES: Record<string, string> = {
|
|||||||
'image/png': 'png',
|
'image/png': 'png',
|
||||||
'image/webp': 'webp',
|
'image/webp': 'webp',
|
||||||
'image/gif': 'gif',
|
'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');
|
const UPLOADS_ROOT = path.join(process.cwd(), 'public', 'uploads');
|
||||||
|
|
||||||
function eventDir(eventId: string) {
|
function eventDir(eventId: string) {
|
||||||
@ -29,51 +22,19 @@ export function photoUrl(eventId: string, filename: string): string {
|
|||||||
return `/uploads/${eventId}/${filename}`;
|
return `/uploads/${eventId}/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** URL to show in a gallery: the thumbnail if one was generated (HEIC/HEIF),
|
/** Writes an uploaded file to disk under a per-event directory and returns
|
||||||
* 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). */
|
* the generated filename (not the full path - callers store just this). */
|
||||||
export async function savePhotoFile(eventId: string, buffer: Buffer, ext: string): Promise<string> {
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
const dir = eventDir(eventId);
|
const dir = eventDir(eventId);
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
const filename = `${randomBytes(12).toString('hex')}.${ext}`;
|
const filename = `${randomBytes(12).toString('hex')}.${ext}`;
|
||||||
await writeFile(path.join(dir, filename), buffer);
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
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);
|
await writeFile(path.join(dir, filename), buffer);
|
||||||
|
|
||||||
return filename;
|
return filename;
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@ -7,48 +7,35 @@ import {
|
|||||||
starPattern,
|
starPattern,
|
||||||
scratchPattern,
|
scratchPattern,
|
||||||
pixelGridPattern,
|
pixelGridPattern,
|
||||||
floralLacePattern,
|
|
||||||
palmLeafPattern,
|
|
||||||
wheatPattern,
|
|
||||||
gradCapPattern,
|
|
||||||
texturePattern,
|
|
||||||
combinePatterns,
|
|
||||||
type Pattern,
|
|
||||||
} from '@/lib/patterns';
|
} from '@/lib/patterns';
|
||||||
import { deriveThemeColors, type ThemeColors } from '@/lib/color';
|
|
||||||
|
|
||||||
export type Theme = {
|
export type Theme = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
/** Light card vs. dark card - drives how resolveTheme derives a full
|
colors: {
|
||||||
* palette from a host's custom primary/accent choice (see color.ts). */
|
bg: string;
|
||||||
mode: 'light' | 'dark';
|
surface: string;
|
||||||
colors: ThemeColors;
|
primary: string;
|
||||||
pattern: Pattern;
|
accent: string;
|
||||||
/** Rebuilds this theme's pattern for an arbitrary color set - used by
|
text: string;
|
||||||
* resolveTheme when a host overrides the default colors. */
|
};
|
||||||
buildPattern: (colors: ThemeColors) => Pattern;
|
pattern: {
|
||||||
|
backgroundImage: string;
|
||||||
|
backgroundSize: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
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).
|
// Colors applied via CSS custom properties (see ThemedInviteLayout).
|
||||||
// Fonts are mapped separately in src/lib/fonts.ts since next/font classes
|
// Fonts are mapped separately in src/lib/fonts.ts since next/font classes
|
||||||
// must be static imports, not computed dynamically. `pattern` is a tileable
|
// must be static imports, not computed dynamically. `pattern` is a tileable
|
||||||
// inline-SVG background (see src/lib/patterns.ts) that gives each theme a
|
// inline-SVG background (see src/lib/patterns.ts) that gives each theme a
|
||||||
// distinct texture, not just a color/font swap. `buildPattern` regenerates
|
// distinct texture, not just a color/font swap.
|
||||||
// that same pattern for a different color set - see resolveTheme below.
|
|
||||||
export const THEMES: Theme[] = [
|
export const THEMES: Theme[] = [
|
||||||
defineTheme({
|
{
|
||||||
id: 'classic-elegant',
|
id: 'classic-elegant',
|
||||||
name: 'Classic Elegant',
|
name: 'Classic Elegant',
|
||||||
emoji: '🥂',
|
emoji: '🥂',
|
||||||
mode: 'light',
|
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#f5f1ea',
|
bg: '#f5f1ea',
|
||||||
surface: '#ffffff',
|
surface: '#ffffff',
|
||||||
@ -56,13 +43,12 @@ export const THEMES: Theme[] = [
|
|||||||
accent: '#b08d57',
|
accent: '#b08d57',
|
||||||
text: '#2b2118',
|
text: '#2b2118',
|
||||||
},
|
},
|
||||||
buildPattern: (c) => combinePatterns(damaskPattern(c.accent), texturePattern('/textures/exclusive-paper.png', 560, 420)),
|
pattern: damaskPattern('#b08d57'),
|
||||||
}),
|
},
|
||||||
defineTheme({
|
{
|
||||||
id: 'birthday-confetti',
|
id: 'birthday-confetti',
|
||||||
name: 'Birthday Confetti',
|
name: 'Birthday Confetti',
|
||||||
emoji: '🎉',
|
emoji: '🎉',
|
||||||
mode: 'light',
|
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#fff3f8',
|
bg: '#fff3f8',
|
||||||
surface: '#ffffff',
|
surface: '#ffffff',
|
||||||
@ -70,13 +56,12 @@ export const THEMES: Theme[] = [
|
|||||||
accent: '#ffb703',
|
accent: '#ffb703',
|
||||||
text: '#3a1030',
|
text: '#3a1030',
|
||||||
},
|
},
|
||||||
buildPattern: (c) => confettiPattern([c.primary, c.accent, '#3dd6c2']),
|
pattern: confettiPattern(['#e0218a', '#ffb703', '#3dd6c2']),
|
||||||
}),
|
},
|
||||||
defineTheme({
|
{
|
||||||
id: 'garden-party',
|
id: 'garden-party',
|
||||||
name: 'Garden Party',
|
name: 'Garden Party',
|
||||||
emoji: '🌿',
|
emoji: '🌿',
|
||||||
mode: 'light',
|
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#f1f7ee',
|
bg: '#f1f7ee',
|
||||||
surface: '#ffffff',
|
surface: '#ffffff',
|
||||||
@ -84,13 +69,12 @@ export const THEMES: Theme[] = [
|
|||||||
accent: '#e0a458',
|
accent: '#e0a458',
|
||||||
text: '#233720',
|
text: '#233720',
|
||||||
},
|
},
|
||||||
buildPattern: (c) => leafPattern(c.primary, c.accent),
|
pattern: leafPattern('#3f6b3a', '#e0a458'),
|
||||||
}),
|
},
|
||||||
defineTheme({
|
{
|
||||||
id: 'modern-minimal',
|
id: 'modern-minimal',
|
||||||
name: 'Modern Minimal',
|
name: 'Modern Minimal',
|
||||||
emoji: '⬛',
|
emoji: '⬛',
|
||||||
mode: 'light',
|
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#fafafa',
|
bg: '#fafafa',
|
||||||
surface: '#ffffff',
|
surface: '#ffffff',
|
||||||
@ -98,13 +82,12 @@ export const THEMES: Theme[] = [
|
|||||||
accent: '#2563eb',
|
accent: '#2563eb',
|
||||||
text: '#111827',
|
text: '#111827',
|
||||||
},
|
},
|
||||||
buildPattern: (c) => polkaDotPattern(c.accent, 1.75),
|
pattern: polkaDotPattern('#2563eb', 1.75),
|
||||||
}),
|
},
|
||||||
defineTheme({
|
{
|
||||||
id: 'holiday-festive',
|
id: 'holiday-festive',
|
||||||
name: 'Holiday Festive',
|
name: 'Holiday Festive',
|
||||||
emoji: '❄️',
|
emoji: '❄️',
|
||||||
mode: 'dark',
|
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#0f2d24',
|
bg: '#0f2d24',
|
||||||
surface: '#153a2e',
|
surface: '#153a2e',
|
||||||
@ -112,13 +95,12 @@ export const THEMES: Theme[] = [
|
|||||||
accent: '#f1c40f',
|
accent: '#f1c40f',
|
||||||
text: '#fdf6ec',
|
text: '#fdf6ec',
|
||||||
},
|
},
|
||||||
buildPattern: (c) => combinePatterns(snowflakePattern(c.accent), texturePattern('/textures/fresh-snow.png', 500, 500)),
|
pattern: snowflakePattern('#f1c40f'),
|
||||||
}),
|
},
|
||||||
defineTheme({
|
{
|
||||||
id: 'baby-shower-pastel',
|
id: 'baby-shower-pastel',
|
||||||
name: 'Baby Shower Pastel',
|
name: 'Baby Shower Pastel',
|
||||||
emoji: '🍼',
|
emoji: '🍼',
|
||||||
mode: 'light',
|
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#f4f1fb',
|
bg: '#f4f1fb',
|
||||||
surface: '#ffffff',
|
surface: '#ffffff',
|
||||||
@ -126,13 +108,12 @@ export const THEMES: Theme[] = [
|
|||||||
accent: '#f6b6c1',
|
accent: '#f6b6c1',
|
||||||
text: '#3a3350',
|
text: '#3a3350',
|
||||||
},
|
},
|
||||||
buildPattern: (c) => starPattern([c.primary, c.accent]),
|
pattern: starPattern(['#8e7cc3', '#f6b6c1']),
|
||||||
}),
|
},
|
||||||
defineTheme({
|
{
|
||||||
id: 'goth-grunge',
|
id: 'goth-grunge',
|
||||||
name: 'Goth Grunge',
|
name: 'Goth Grunge',
|
||||||
emoji: '💀',
|
emoji: '💀',
|
||||||
mode: 'dark',
|
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#121212',
|
bg: '#121212',
|
||||||
surface: '#1c1c1c',
|
surface: '#1c1c1c',
|
||||||
@ -140,13 +121,12 @@ export const THEMES: Theme[] = [
|
|||||||
accent: '#9333ea',
|
accent: '#9333ea',
|
||||||
text: '#e5e5e5',
|
text: '#e5e5e5',
|
||||||
},
|
},
|
||||||
buildPattern: (c) => combinePatterns(scratchPattern(c.accent), texturePattern('/textures/dark-leather.png', 398, 484)),
|
pattern: scratchPattern('#9333ea'),
|
||||||
}),
|
},
|
||||||
defineTheme({
|
{
|
||||||
id: 'retro-arcade',
|
id: 'retro-arcade',
|
||||||
name: 'Retro Arcade',
|
name: 'Retro Arcade',
|
||||||
emoji: '🕹️',
|
emoji: '🕹️',
|
||||||
mode: 'dark',
|
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#0d0221',
|
bg: '#0d0221',
|
||||||
surface: '#1a0b3d',
|
surface: '#1a0b3d',
|
||||||
@ -154,64 +134,8 @@ export const THEMES: Theme[] = [
|
|||||||
accent: '#00e5ff',
|
accent: '#00e5ff',
|
||||||
text: '#eafffb',
|
text: '#eafffb',
|
||||||
},
|
},
|
||||||
buildPattern: (c) => pixelGridPattern(c.accent, [c.primary, c.accent]),
|
pattern: pixelGridPattern('#00e5ff', ['#ff2e88', '#00e5ff']),
|
||||||
}),
|
},
|
||||||
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;
|
export const DEFAULT_THEME_ID = THEMES[0].id;
|
||||||
@ -219,27 +143,3 @@ export const DEFAULT_THEME_ID = THEMES[0].id;
|
|||||||
export function getTheme(id: string): Theme {
|
export function getTheme(id: string): Theme {
|
||||||
return THEMES.find((t) => t.id === id) ?? THEMES[0];
|
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) };
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { THEMES } from '@/lib/themes';
|
import { THEMES } from '@/lib/themes';
|
||||||
import { isValidDatetimeLocal } from '@/lib/timezone';
|
import { isValidDatetimeLocal } from '@/lib/timezone';
|
||||||
import { isValidHexColor } from '@/lib/color';
|
|
||||||
|
|
||||||
const themeIds = THEMES.map((t) => t.id) as [string, ...string[]];
|
const themeIds = THEMES.map((t) => t.id) as [string, ...string[]];
|
||||||
|
|
||||||
@ -12,12 +11,6 @@ const optionalDatetimeLocal = z
|
|||||||
.optional()
|
.optional()
|
||||||
.or(z.literal(''));
|
.or(z.literal(''));
|
||||||
|
|
||||||
const optionalHexColor = z
|
|
||||||
.string()
|
|
||||||
.refine((v) => !v || isValidHexColor(v), 'Enter a valid color')
|
|
||||||
.optional()
|
|
||||||
.or(z.literal(''));
|
|
||||||
|
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, 'Password must be at least 8 characters')
|
||||||
@ -64,8 +57,6 @@ export const eventFormSchema = z
|
|||||||
description: z.string().trim().max(5000).default(''),
|
description: z.string().trim().max(5000).default(''),
|
||||||
contactInfo: z.string().trim().max(300).default(''),
|
contactInfo: z.string().trim().max(300).default(''),
|
||||||
theme: z.enum(themeIds),
|
theme: z.enum(themeIds),
|
||||||
themePrimaryColor: optionalHexColor,
|
|
||||||
themeAccentColor: optionalHexColor,
|
|
||||||
timezone: z.string().trim().min(1, 'Timezone is required'),
|
timezone: z.string().trim().min(1, 'Timezone is required'),
|
||||||
eventDate: datetimeLocal,
|
eventDate: datetimeLocal,
|
||||||
endDate: optionalDatetimeLocal,
|
endDate: optionalDatetimeLocal,
|
||||||
@ -97,21 +88,6 @@ export const rsvpSchema = z.object({
|
|||||||
note: z.string().trim().max(1000).default(''),
|
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({
|
export const commentSchema = z.object({
|
||||||
eventId: z.string().min(1),
|
eventId: z.string().min(1),
|
||||||
authorName: z.string().trim().min(1, 'Name is required').max(150),
|
authorName: z.string().trim().min(1, 'Name is required').max(150),
|
||||||
@ -129,11 +105,6 @@ export const inviteCollaboratorSchema = z.object({
|
|||||||
email: z.string().trim().email('Enter a valid email'),
|
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({
|
export const photoUploadSchema = z.object({
|
||||||
eventId: z.string().min(1),
|
eventId: z.string().min(1),
|
||||||
uploaderName: z.string().trim().max(100).default(''),
|
uploaderName: z.string().trim().max(100).default(''),
|
||||||
|
|||||||
9
src/types/heic-convert.d.ts
vendored
9
src/types/heic-convert.d.ts
vendored
@ -1,9 +0,0 @@
|
|||||||
declare module 'heic-convert' {
|
|
||||||
interface ConvertOptions {
|
|
||||||
buffer: Buffer | ArrayBuffer | Uint8Array;
|
|
||||||
format: 'JPEG' | 'PNG';
|
|
||||||
quality?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function convert(options: ConvertOptions): Promise<ArrayBuffer>;
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user