Initial commit: E-Invite, a self-hosted Evite-lite
Docker-ready Next.js + Prisma/Postgres e-invitation app: themed invite pages, RSVP, comments, share links, email + SMS invites with reply-to-RSVP-by-text, co-hosts, photo dropbox, and browser push notifications.
This commit is contained in:
commit
2c78112a4f
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
43
.env.example
Normal file
43
.env.example
Normal file
@ -0,0 +1,43 @@
|
||||
# Postgres connection used by Prisma
|
||||
DATABASE_URL="postgresql://einvite:einvite@postgres:5432/einvite?schema=public"
|
||||
|
||||
# Base URL the app is served from (used to build share links & email links)
|
||||
APP_URL="http://localhost:3100"
|
||||
|
||||
# Secret used to sign session cookies - set to a long random string in production
|
||||
SESSION_SECRET="change-me-to-a-long-random-string"
|
||||
|
||||
# Set to "true" once the app is served over HTTPS (e.g. behind a reverse proxy
|
||||
# terminating TLS) so session cookies get the Secure flag. Leave "false" for
|
||||
# the default plain-HTTP docker-compose setup, otherwise logins will silently
|
||||
# stop working.
|
||||
COOKIE_SECURE="false"
|
||||
|
||||
# SMTP settings for outbound email. Defaults point at the bundled Mailpit
|
||||
# dev catcher (web UI at http://localhost:8025). Swap these for a real
|
||||
# provider (SES, Postmark, Gmail, etc.) in production - no code changes needed.
|
||||
SMTP_HOST="mailpit"
|
||||
SMTP_PORT="1025"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="E-Invite <invites@example.com>"
|
||||
SMTP_SECURE="false"
|
||||
|
||||
# Browser push notifications (optional - hosts opt in per-device from
|
||||
# Settings). Generate your own pair with:
|
||||
# node -e "console.log(JSON.stringify(require('web-push').generateVAPIDKeys(),null,2))"
|
||||
# The public key is baked into the client bundle at *build* time (not just
|
||||
# runtime), so changing it requires a rebuild (`docker compose up --build`).
|
||||
# Leave both blank to disable the feature entirely - push notifications are
|
||||
# never required, email notifications always work regardless.
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY="BJBjEBsaKn8vaj6DiqpuUMhriDgqgbgiilG0G6ejbTlu8PTfnS6DT4WHUXkBXXOZCnpc6s5TQX_M4pkrhC6y-Cc"
|
||||
VAPID_PRIVATE_KEY="VsCabG9YTAo9mbL9qGhqsf7JnBfwB6s4JY4us1gY9Y0"
|
||||
VAPID_CONTACT_EMAIL="admin@example.com"
|
||||
|
||||
# SMS invites + reply-to-RSVP-by-text (optional). Leave all three blank to
|
||||
# disable - guests can still always be invited/RSVP by email. Get these from
|
||||
# https://console.twilio.com (Account SID + Auth Token on the dashboard, the
|
||||
# phone number from Phone Numbers > Manage > Active Numbers).
|
||||
TWILIO_ACCOUNT_SID=""
|
||||
TWILIO_AUTH_TOKEN=""
|
||||
TWILIO_PHONE_NUMBER=""
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
/public/uploads/*
|
||||
!/public/uploads/.gitkeep
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@ -0,0 +1,37 @@
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
# ---- deps: install full dependency tree (needed to build) ----
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
# ---- builder: compile the Next.js app ----
|
||||
FROM base AS builder
|
||||
# Next.js inlines NEXT_PUBLIC_* vars into the client bundle at build time, so
|
||||
# this has to be a build arg, not just a runtime environment variable.
|
||||
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
|
||||
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=$NEXT_PUBLIC_VAPID_PUBLIC_KEY
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- runner: production image ----
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
COPY docker/entrypoint.sh ./docker/entrypoint.sh
|
||||
RUN chmod +x ./docker/entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["./docker/entrypoint.sh"]
|
||||
CMD ["npm", "start"]
|
||||
187
README.md
Normal file
187
README.md
Normal file
@ -0,0 +1,187 @@
|
||||
# E-Invite
|
||||
|
||||
A stripped-down, self-hosted alternative to Evite: create themed online invitations, share a
|
||||
single link with guests, collect RSVPs, and let guests leave comments — all Docker-ready.
|
||||
|
||||
## Features
|
||||
|
||||
- **Host accounts** — email + password signup/login, forgot/reset password via email.
|
||||
- **Themed invites** — pick from several built-in themes (colors + fonts) per event.
|
||||
- **Share links** — every event gets a unique, unguessable public URL. No guest accounts needed.
|
||||
- **RSVP** — guests respond yes/no/maybe with a headcount and optional note; host and guest both
|
||||
get a confirmation email.
|
||||
- **Comments / well-wishes** — guests can leave a public message on the invite; hosts can hide or
|
||||
delete any comment.
|
||||
- **Email invites** — hosts can paste in a list of guest emails and send the invite link to all of
|
||||
them at once.
|
||||
- **Co-hosts** — invite another person by email to help manage an event (edit details, view RSVPs,
|
||||
moderate comments); they accept via emailed link and sign up/log in. Only the original host can
|
||||
delete the event or manage who else has access.
|
||||
- **End times, food/drinks** — optional event end time (shown as a date range) and simple
|
||||
food/drinks-available badges on the invite.
|
||||
- **Photo dropbox** — guests can upload photos once the event starts; hosts can set an auto-close
|
||||
time or close uploads manually at any time, and can hide/delete individual photos.
|
||||
- **Email verification** — new accounts get a verification email; a dismissable-once-verified
|
||||
banner nudges unverified hosts, but doesn't block using the app.
|
||||
- **Notification preferences + browser push** — hosts can toggle which events (RSVP/comment/co-host
|
||||
accepted) email them, and separately opt in to browser push notifications per-device from
|
||||
Settings. See "Browser push notifications" below for the HTTPS caveat.
|
||||
- **Text (SMS) invites + reply-to-RSVP** — the guest list accepts phone numbers alongside emails;
|
||||
phone-based guests get a text with the invite link and can also just reply YES/NO/MAYBE (with an
|
||||
optional headcount, e.g. "YES 3") directly to RSVP, no link click needed. Requires a Twilio
|
||||
account — see "Text invites via Twilio" below. Fully optional; email-only hosts see no change.
|
||||
|
||||
### Robustness
|
||||
|
||||
- **RSVP dedup** — resubmitting an RSVP with the same email updates the existing response instead
|
||||
of creating a duplicate row, so headcounts stay accurate.
|
||||
- **Rate limiting** — in-memory limits on RSVP/comment submission, login, signup, forgot-password
|
||||
and invite-sending guard against spam and brute-force/credential-stuffing attempts. Login/RSVP
|
||||
limits combine IP (when a reverse proxy sets `X-Forwarded-For`) with a more specific key (email,
|
||||
event) so a shared/unknown IP can't lock out every visitor.
|
||||
- **Honeypot** — the public RSVP and comment forms include a hidden field real users never fill;
|
||||
submissions that fill it are silently dropped as bots.
|
||||
- **Resilient email** — RSVP/comment/event actions never fail because SMTP is down or
|
||||
misconfigured; the guest-facing action still succeeds (data is already saved) and the failure is
|
||||
logged server-side. The one exception is the host-facing "send invites" action, which reports
|
||||
actual delivery failures back to the host.
|
||||
- **Health check** — `GET /api/health` verifies DB connectivity; wired into both the Dockerfile
|
||||
`HEALTHCHECK` and the `app` service's compose healthcheck.
|
||||
- **Tests** — `npm test` runs the Vitest suite (validation schemas, rate limiter, email/phone
|
||||
parsing, SMS reply parsing, Twilio signature verification, HTML-escaping helpers).
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # edit SESSION_SECRET, SMTP_* etc. for production use
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
This starts three containers:
|
||||
|
||||
| Service | Purpose | URL |
|
||||
|------------|--------------------------------------------|---------------------------|
|
||||
| `app` | The Next.js application | http://localhost:3100 |
|
||||
| `postgres` | Database (migrations run automatically) | internal only |
|
||||
| `mailpit` | Dev SMTP catcher — view all sent emails here | http://localhost:8025 |
|
||||
|
||||
On startup, the app container automatically runs `prisma migrate deploy` before starting the
|
||||
server, so a fresh `docker compose up` is all that's needed. Uploaded photos are stored on disk
|
||||
under `public/uploads`, backed by the `uploads-data` named volume so they survive rebuilds.
|
||||
|
||||
## Using a real SMTP provider
|
||||
|
||||
By default, `SMTP_HOST`/`SMTP_PORT` point at the bundled Mailpit container so email works out of
|
||||
the box in development. To send real email in production, edit `.env` (or the `app` service
|
||||
environment in `docker-compose.yml`) with your provider's SMTP credentials — no code changes
|
||||
required:
|
||||
|
||||
```
|
||||
SMTP_HOST=smtp.yourprovider.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-smtp-username
|
||||
SMTP_PASS=your-smtp-password
|
||||
SMTP_FROM="Your Name <invites@yourdomain.com>"
|
||||
SMTP_SECURE=false
|
||||
```
|
||||
|
||||
Also update `APP_URL` to your real public URL so links in emails point to the right place, and set
|
||||
`SESSION_SECRET` to a long random string.
|
||||
|
||||
## Text invites via Twilio
|
||||
|
||||
Adding a phone number to an event's guest list (alongside or instead of emails) sends a text
|
||||
invite and lets that guest RSVP by replying YES, NO, or MAYBE — optionally with a headcount, e.g.
|
||||
"YES 3" — directly to the text, no link click needed. This is entirely optional: leave the
|
||||
`TWILIO_*` vars blank and the guest list just works with emails only, same as before.
|
||||
|
||||
To enable it:
|
||||
|
||||
1. Create a [Twilio](https://www.twilio.com) account and buy a phone number capable of sending/
|
||||
receiving SMS.
|
||||
2. Set these in `.env` (or the `app` service environment in `docker-compose.yml`):
|
||||
```
|
||||
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
TWILIO_AUTH_TOKEN=your-auth-token
|
||||
TWILIO_PHONE_NUMBER=+15551234567
|
||||
```
|
||||
Both values are on the Twilio Console dashboard; the phone number is under
|
||||
**Phone Numbers → Manage → Active Numbers**.
|
||||
3. In the Twilio Console, open that phone number's configuration and set **"A message comes in"**
|
||||
to a webhook: `POST https://your-real-domain.com/api/sms/webhook` (must be `APP_URL` from your
|
||||
`.env` plus `/api/sms/webhook` — the webhook verifies Twilio's signature against that exact URL,
|
||||
so a mismatch here causes every inbound reply to be silently rejected as unverified).
|
||||
4. `docker compose up -d --force-recreate app` to pick up the new env vars (no rebuild needed —
|
||||
these aren't `NEXT_PUBLIC_*`, so they're read at runtime, not baked into the build).
|
||||
|
||||
**Caveats worth knowing:**
|
||||
- There's one shared Twilio number for the whole app instance (not one per event), so an inbound
|
||||
reply is matched to whichever event most recently texted that phone number. If you invite the
|
||||
same guest phone number to two events in quick succession before they reply to the first, their
|
||||
reply could land on the wrong event. For most single-host, single-event use this never comes up;
|
||||
if you're running many concurrent events through one number, prefer the email flow for guests
|
||||
invited to more than one active event at a time.
|
||||
- SMS costs money per message once you're past Twilio's trial credits — this app doesn't do any
|
||||
cost tracking or budgeting, that's on the Twilio side.
|
||||
- A misconfigured or suspended Twilio account behaves like SMTP being down: the guest still gets
|
||||
added to the guest list, the host just sees "text(s) failed" in the send-invites result instead
|
||||
of the whole action erroring out.
|
||||
|
||||
## Deploying behind HTTPS
|
||||
|
||||
Session cookies are **not** marked `Secure` by default, because the bundled docker-compose stack
|
||||
serves plain HTTP and a `Secure` cookie would silently never be sent back by the browser, breaking
|
||||
every login. Once you have a reverse proxy terminating TLS in front of the app, set
|
||||
`COOKIE_SECURE=true` so session cookies get the `Secure` flag.
|
||||
|
||||
## Browser push notifications
|
||||
|
||||
Hosts can optionally enable browser push notifications per-device from Settings, as a companion
|
||||
to (not a replacement for) email notifications. This requires:
|
||||
|
||||
1. VAPID keys set in `.env` (`NEXT_PUBLIC_VAPID_PUBLIC_KEY` + `VAPID_PRIVATE_KEY`) - `.env.example`
|
||||
ships with a working default pair; generate your own with:
|
||||
```bash
|
||||
node -e "console.log(JSON.stringify(require('web-push').generateVAPIDKeys(),null,2))"
|
||||
```
|
||||
2. **HTTPS.** The browser Push API refuses to work over plain HTTP on anything other than
|
||||
`localhost` - this is a browser platform restriction, not something this app can work around.
|
||||
If you're accessing the app via `http://localhost:3100` directly, push works out of the box in
|
||||
development. Once deployed behind a real domain, you need a reverse proxy terminating TLS in
|
||||
front of the app (see "Deploying behind HTTPS" above) before the "Enable push notifications"
|
||||
button will do anything.
|
||||
|
||||
The public key is inlined into the client JS bundle at **build** time (not read at container
|
||||
startup), so changing it requires `docker compose up --build`, not just a restart. Leaving both
|
||||
keys blank disables the feature entirely - the Settings page just says push isn't configured, and
|
||||
email notifications are unaffected either way.
|
||||
|
||||
## Local development (without Docker)
|
||||
|
||||
Requires Node 20+ and a running Postgres instance.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # point DATABASE_URL at your local Postgres, SMTP_HOST at a local catcher
|
||||
npx prisma migrate dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Run the test suite with:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
- `prisma/schema.prisma` — data model (User, Session, Event, Rsvp, GuestInvite, Comment)
|
||||
- `src/lib/` — session/auth, mail (Nodemailer templates), sms (Twilio send/verify), theme presets,
|
||||
validation (zod), rate limiting, email/phone list parsing, SMS reply parsing
|
||||
- `src/app/actions/` — Server Actions for auth, events, RSVP, comments, invites
|
||||
- `src/app/(auth)/` — signup/login/forgot-password/reset-password pages
|
||||
- `src/app/dashboard/` — host dashboard, event editor, guest/RSVP/comment management
|
||||
- `src/app/invite/[slug]/` — the public, themed invite page guests land on
|
||||
- `src/app/api/health/` — health check endpoint used by Docker/compose
|
||||
- `src/app/api/sms/webhook/` — inbound Twilio webhook for reply-to-RSVP-by-text
|
||||
- `src/app/error.tsx`, `src/app/not-found.tsx` — custom error/404 pages
|
||||
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal file
@ -0,0 +1,65 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# Baked into the client bundle at build time - see Dockerfile.
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}
|
||||
ports:
|
||||
- "3100:3000"
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://einvite:einvite@postgres:5432/einvite?schema=public}
|
||||
APP_URL: ${APP_URL:-http://localhost:3100}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-to-a-long-random-string}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
SMTP_HOST: ${SMTP_HOST:-mailpit}
|
||||
SMTP_PORT: ${SMTP_PORT:-1025}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-E-Invite <invites@example.com>}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_CONTACT_EMAIL: ${VAPID_CONTACT_EMAIL:-admin@example.com}
|
||||
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID:-}
|
||||
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN:-}
|
||||
TWILIO_PHONE_NUMBER: ${TWILIO_PHONE_NUMBER:-}
|
||||
volumes:
|
||||
- uploads-data:/app/public/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: einvite
|
||||
POSTGRES_PASSWORD: einvite
|
||||
POSTGRES_DB: einvite
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U einvite"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
- "8025:8025" # web UI: view caught emails at http://localhost:8025
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
uploads-data:
|
||||
8
docker/entrypoint.sh
Normal file
8
docker/entrypoint.sh
Normal file
@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
echo "Starting app..."
|
||||
exec "$@"
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
12
next.config.js
Normal file
12
next.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Server Actions default to a 1MB request body limit; photo uploads
|
||||
// (src/lib/photoStorage.ts) allow up to 8MB, so raise the ceiling to match.
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '10mb',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
3843
package-lock.json
generated
Normal file
3843
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "einvite",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"libphonenumber-js": "^1.13.8",
|
||||
"nanoid": "^5.0.7",
|
||||
"next": "^16.2.10",
|
||||
"nodemailer": "^9.0.3",
|
||||
"prisma": "^5.20.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"twilio": "^6.0.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/nodemailer": "^7.0.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
121
prisma/migrations/20260710203158_init/migration.sql
Normal file
121
prisma/migrations/20260710203158_init/migration.sql
Normal file
@ -0,0 +1,121 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RsvpStatus" AS ENUM ('YES', 'NO', 'MAYBE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"resetToken" TEXT,
|
||||
"resetTokenExpiry" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Event" (
|
||||
"id" TEXT NOT NULL,
|
||||
"hostId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"theme" TEXT NOT NULL DEFAULT 'classic-elegant',
|
||||
"eventDate" TIMESTAMP(3) NOT NULL,
|
||||
"location" TEXT NOT NULL DEFAULT '',
|
||||
"slug" TEXT NOT NULL,
|
||||
"rsvpDeadline" TIMESTAMP(3),
|
||||
"allowPlusOnes" BOOLEAN NOT NULL DEFAULT true,
|
||||
"commentsEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Rsvp" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"guestName" TEXT NOT NULL,
|
||||
"guestEmail" TEXT NOT NULL,
|
||||
"status" "RsvpStatus" NOT NULL,
|
||||
"numGuests" INTEGER NOT NULL DEFAULT 1,
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Rsvp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GuestInvite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT '',
|
||||
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GuestInvite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"authorName" TEXT NOT NULL,
|
||||
"authorEmail" TEXT NOT NULL DEFAULT '',
|
||||
"body" TEXT NOT NULL,
|
||||
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_resetToken_key" ON "User"("resetToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_hostId_idx" ON "Event"("hostId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Rsvp_eventId_idx" ON "Rsvp"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GuestInvite_eventId_idx" ON "GuestInvite"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_eventId_idx" ON "Comment"("eventId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Rsvp" ADD CONSTRAINT "Rsvp_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GuestInvite" ADD CONSTRAINT "GuestInvite_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,9 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Rsvp_eventId_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Rsvp" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Rsvp_eventId_guestEmail_key" ON "Rsvp"("eventId", "guestEmail");
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollaboratorStatus" AS ENUM ('PENDING', 'ACCEPTED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "drinksProvided" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "endDate" TIMESTAMP(3),
|
||||
ADD COLUMN "foodProvided" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Rsvp" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCollaborator" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"invitedEmail" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"status" "CollaboratorStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"acceptedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "EventCollaborator_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EventCollaborator_token_key" ON "EventCollaborator"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCollaborator_eventId_idx" ON "EventCollaborator"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EventCollaborator_eventId_invitedEmail_key" ON "EventCollaborator"("eventId", "invitedEmail");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCollaborator" ADD CONSTRAINT "EventCollaborator_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCollaborator" ADD CONSTRAINT "EventCollaborator_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
23
prisma/migrations/20260711000000_photo_dropbox/migration.sql
Normal file
23
prisma/migrations/20260711000000_photo_dropbox/migration.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "photoCloseDate" TIMESTAMP(3),
|
||||
ADD COLUMN "photosClosed" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "photosEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Photo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"uploaderName" TEXT NOT NULL DEFAULT '',
|
||||
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Photo_eventId_idx" ON "Photo"("eventId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "verificationToken" TEXT,
|
||||
ADD COLUMN "verificationTokenExpiry" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_verificationToken_key" ON "User"("verificationToken");
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "contactInfo" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "occasion" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "notifyOnCoHostAccepted" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "notifyOnComment" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "notifyOnRsvp" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PushSubscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "GuestInvite_eventId_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'UTC';
|
||||
|
||||
-- AlterTable: nullable first so existing rows can be backfilled, then
|
||||
-- tightened to NOT NULL - a plain NOT NULL ADD COLUMN would fail against
|
||||
-- any pre-existing EventCollaborator rows.
|
||||
ALTER TABLE "EventCollaborator" ADD COLUMN "expiresAt" TIMESTAMP(3);
|
||||
UPDATE "EventCollaborator" SET "expiresAt" = "createdAt" + INTERVAL '7 days' WHERE "expiresAt" IS NULL;
|
||||
ALTER TABLE "EventCollaborator" ALTER COLUMN "expiresAt" SET NOT NULL;
|
||||
|
||||
-- Deduplicate before adding the unique constraint below, keeping the most
|
||||
-- recent invite per (eventId, email) - defensive in case any duplicates
|
||||
-- accumulated before this constraint existed.
|
||||
DELETE FROM "GuestInvite" a USING "GuestInvite" b
|
||||
WHERE a."eventId" = b."eventId" AND a.email = b.email AND a."sentAt" < b."sentAt";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GuestInvite_eventId_email_key" ON "GuestInvite"("eventId", "email");
|
||||
@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "restrictRsvpToInvited" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable: default false going forward, but every existing row was
|
||||
-- created under the old logic that only inserted a GuestInvite after a
|
||||
-- successful send, so backfill those as true.
|
||||
ALTER TABLE "GuestInvite" ADD COLUMN "emailSent" BOOLEAN NOT NULL DEFAULT false;
|
||||
UPDATE "GuestInvite" SET "emailSent" = true;
|
||||
24
prisma/migrations/20260711070000_sms_support/migration.sql
Normal file
24
prisma/migrations/20260711070000_sms_support/migration.sql
Normal file
@ -0,0 +1,24 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "GuestInvite" ADD COLUMN "phone" TEXT,
|
||||
ADD COLUMN "smsSent" BOOLEAN NOT NULL DEFAULT false,
|
||||
ALTER COLUMN "email" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Rsvp" ADD COLUMN "guestPhone" TEXT,
|
||||
ALTER COLUMN "guestEmail" DROP NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GuestInvite_eventId_phone_key" ON "GuestInvite"("eventId", "phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Rsvp_eventId_guestPhone_key" ON "Rsvp"("eventId", "guestPhone");
|
||||
|
||||
-- CheckConstraint: not expressible in Prisma's schema language, so added by
|
||||
-- hand. Every existing row already has a non-null guestEmail/email (the
|
||||
-- prior schema required it), so no backfill is needed before adding these.
|
||||
ALTER TABLE "Rsvp" ADD CONSTRAINT "rsvp_guest_contact_check"
|
||||
CHECK ("guestEmail" IS NOT NULL OR "guestPhone" IS NOT NULL);
|
||||
|
||||
ALTER TABLE "GuestInvite" ADD CONSTRAINT "guestinvite_contact_check"
|
||||
CHECK ("email" IS NOT NULL OR "phone" IS NOT NULL);
|
||||
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
188
prisma/schema.prisma
Normal file
188
prisma/schema.prisma
Normal file
@ -0,0 +1,188 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
resetToken String? @unique
|
||||
resetTokenExpiry DateTime?
|
||||
emailVerified Boolean @default(false)
|
||||
verificationToken String? @unique
|
||||
verificationTokenExpiry DateTime?
|
||||
notifyOnRsvp Boolean @default(true)
|
||||
notifyOnComment Boolean @default(true)
|
||||
notifyOnCoHostAccepted Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
sessions Session[]
|
||||
events Event[]
|
||||
collaboratorInvites EventCollaborator[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
endpoint String @unique
|
||||
p256dh String
|
||||
auth String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum RsvpStatus {
|
||||
YES
|
||||
NO
|
||||
MAYBE
|
||||
}
|
||||
|
||||
enum CollaboratorStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
hostId String
|
||||
title String
|
||||
occasion String @default("")
|
||||
description String @default("")
|
||||
contactInfo String @default("")
|
||||
theme String @default("classic-elegant")
|
||||
timezone String @default("UTC")
|
||||
eventDate DateTime
|
||||
endDate DateTime?
|
||||
location String @default("")
|
||||
slug String @unique
|
||||
rsvpDeadline DateTime?
|
||||
restrictRsvpToInvited Boolean @default(false)
|
||||
allowPlusOnes Boolean @default(true)
|
||||
commentsEnabled Boolean @default(true)
|
||||
foodProvided Boolean @default(false)
|
||||
drinksProvided Boolean @default(false)
|
||||
photosEnabled Boolean @default(true)
|
||||
photoCloseDate DateTime?
|
||||
photosClosed Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
host User @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||||
rsvps Rsvp[]
|
||||
comments Comment[]
|
||||
guestInvites GuestInvite[]
|
||||
collaborators EventCollaborator[]
|
||||
photos Photo[]
|
||||
|
||||
@@index([hostId])
|
||||
}
|
||||
|
||||
model Photo {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
filename String
|
||||
uploaderName String @default("")
|
||||
hidden Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model EventCollaborator {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
invitedEmail String
|
||||
userId String?
|
||||
status CollaboratorStatus @default(PENDING)
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([eventId, invitedEmail])
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model Rsvp {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
guestName String
|
||||
// Exactly one of these identifies the guest, depending on whether they
|
||||
// RSVP'd via the web form (email) or by replying to an SMS invite
|
||||
// (phone) - enforced by a CHECK constraint added in the migration since
|
||||
// Prisma's schema language doesn't express "at least one of" directly.
|
||||
guestEmail String?
|
||||
guestPhone String?
|
||||
status RsvpStatus
|
||||
numGuests Int @default(1)
|
||||
note String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([eventId, guestEmail])
|
||||
@@unique([eventId, guestPhone])
|
||||
}
|
||||
|
||||
model GuestInvite {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
// Exactly one of email/phone identifies this guest-list entry (CHECK
|
||||
// constraint added in the migration).
|
||||
email String?
|
||||
phone String?
|
||||
name String @default("")
|
||||
// Being on this list (guest-list membership, used to gate RSVPs when
|
||||
// Event.restrictRsvpToInvited is on, and to route an inbound SMS reply to
|
||||
// the right event) is independent of whether the invite itself actually
|
||||
// went out - emailSent/smsSent are purely informational.
|
||||
emailSent Boolean @default(false)
|
||||
smsSent Boolean @default(false)
|
||||
sentAt DateTime @default(now())
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([eventId, email])
|
||||
@@unique([eventId, phone])
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
authorName String
|
||||
authorEmail String @default("")
|
||||
body String
|
||||
hidden Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
28
public/sw.js
Normal file
28
public/sw.js
Normal file
@ -0,0 +1,28 @@
|
||||
self.addEventListener('push', (event) => {
|
||||
let payload = { title: 'E-Invite', body: '' };
|
||||
try {
|
||||
if (event.data) payload = event.data.json();
|
||||
} catch {
|
||||
// Ignore malformed payloads rather than crashing the service worker.
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title || 'E-Invite', {
|
||||
body: payload.body || '',
|
||||
data: { url: payload.url || '/' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || '/';
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if (client.url === url && 'focus' in client) return client.focus();
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
0
public/uploads/.gitkeep
Normal file
0
public/uploads/.gitkeep
Normal file
47
src/app/(auth)/forgot-password/page.tsx
Normal file
47
src/app/(auth)/forgot-password/page.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { forgotPasswordAction, type ActionState } from '@/app/actions/auth';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [state, formAction] = useActionState(forgotPasswordAction, initialState);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Enter your email and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<form action={formAction} className="mt-8 flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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>Send reset link</SubmitButton>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Back to log in
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
73
src/app/(auth)/login/page.tsx
Normal file
73
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useActionState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { loginAction, type ActionState } from '@/app/actions/auth';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const next = searchParams.get('next') ?? '';
|
||||
const [state, formAction] = useActionState(loginAction, initialState);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Log in</h1>
|
||||
|
||||
<form action={formAction} className="mt-8 flex flex-col gap-4">
|
||||
<input type="hidden" name="next" value={next} />
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
|
||||
<SubmitButton>Log in</SubmitButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex justify-between text-sm">
|
||||
<Link
|
||||
href={next ? `/signup?next=${encodeURIComponent(next)}` : '/signup'}
|
||||
className="font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Create an account
|
||||
</Link>
|
||||
<Link href="/forgot-password" className="text-gray-500 hover:text-gray-700">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
78
src/app/(auth)/reset-password/page.tsx
Normal file
78
src/app/(auth)/reset-password/page.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useActionState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { resetPasswordAction, type ActionState } from '@/app/actions/auth';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token') ?? '';
|
||||
const [state, formAction] = useActionState(resetPasswordAction, initialState);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Set a new password</h1>
|
||||
|
||||
{!token ? (
|
||||
<p className="mt-4 text-sm text-red-600">
|
||||
This reset link is missing its token. Please use the link from your email.
|
||||
</p>
|
||||
) : (
|
||||
<form action={formAction} className="mt-8 flex flex-col gap-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
pattern="(?=.*[A-Za-z])(?=.*\d).{8,}"
|
||||
title="At least 8 characters, including a letter and a number"
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">At least 8 characters, with a letter and a number.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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>Update password</SubmitButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Back to log in
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
100
src/app/(auth)/signup/page.tsx
Normal file
100
src/app/(auth)/signup/page.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useActionState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { signupAction, type ActionState } from '@/app/actions/auth';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SignupForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SignupForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const next = searchParams.get('next') ?? '';
|
||||
const [state, formAction] = useActionState(signupAction, initialState);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Create your account</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Start hosting themed invitations in minutes.</p>
|
||||
|
||||
<form action={formAction} className="mt-8 flex flex-col gap-4">
|
||||
<input type="hidden" name="next" value={next} />
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
pattern="(?=.*[A-Za-z])(?=.*\d).{8,}"
|
||||
title="At least 8 characters, including a letter and a number"
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">At least 8 characters, with a letter and a number.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
|
||||
<SubmitButton>Sign up</SubmitButton>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href={next ? `/login?next=${encodeURIComponent(next)}` : '/login'}
|
||||
className="font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
217
src/app/actions/auth.ts
Normal file
217
src/app/actions/auth.ts
Normal file
@ -0,0 +1,217 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createSession, destroySession, hashPassword, requireUser, verifyPassword } from '@/lib/session';
|
||||
import { sendPasswordResetEmail, sendVerificationEmail } from '@/lib/mail';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
import {
|
||||
signupSchema,
|
||||
loginSchema,
|
||||
forgotPasswordSchema,
|
||||
resetPasswordSchema,
|
||||
} from '@/lib/validation';
|
||||
|
||||
const TOO_MANY_ATTEMPTS = 'Too many attempts. Please wait a few minutes and try again.';
|
||||
|
||||
export type ActionState = { error?: string; success?: string };
|
||||
|
||||
// Only ever redirect to a same-site relative path after auth (e.g. back to
|
||||
// an invite-accept page) - a `next` value like "//evil.com" or
|
||||
// "https://evil.com" would be an open redirect, so anything not starting
|
||||
// with exactly one leading slash falls back to the dashboard.
|
||||
function safeNextPath(next: FormDataEntryValue | null): string {
|
||||
if (typeof next === 'string' && next.startsWith('/') && !next.startsWith('//')) {
|
||||
return next;
|
||||
}
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
export async function signupAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
|
||||
const ip = await getClientIp();
|
||||
if (!checkRateLimit(`signup:${ip}`, 10, 60 * 60 * 1000)) {
|
||||
return { error: TOO_MANY_ATTEMPTS };
|
||||
}
|
||||
|
||||
const parsed = signupSchema.safeParse({
|
||||
name: formData.get('name'),
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const { name, email, password } = parsed.data;
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (existing) {
|
||||
return { error: 'An account with that email already exists' };
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const verificationToken = randomBytes(32).toString('hex');
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
verificationToken,
|
||||
verificationTokenExpiry: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
await sendVerificationEmail({
|
||||
to: user.email,
|
||||
verifyUrl: appUrl(`/verify-email/${verificationToken}`),
|
||||
});
|
||||
|
||||
await createSession(user.id);
|
||||
redirect(safeNextPath(formData.get('next')));
|
||||
}
|
||||
|
||||
export async function loginAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
|
||||
const parsed = loginSchema.safeParse({
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const { email, password } = parsed.data;
|
||||
|
||||
const ip = await getClientIp();
|
||||
// Keyed on ip+email (not just ip) so a shared/unknown ip in front of the
|
||||
// app can't lock out every user, while still slowing down credential
|
||||
// stuffing against one account.
|
||||
if (!checkRateLimit(`login:${ip}:${email.toLowerCase()}`, 8, 15 * 60 * 1000)) {
|
||||
return { error: TOO_MANY_ATTEMPTS };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (!user) {
|
||||
return { error: 'Invalid email or password' };
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return { error: 'Invalid email or password' };
|
||||
}
|
||||
|
||||
await createSession(user.id);
|
||||
redirect(safeNextPath(formData.get('next')));
|
||||
}
|
||||
|
||||
export async function logoutAction() {
|
||||
await destroySession();
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
export async function forgotPasswordAction(
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const parsed = forgotPasswordSchema.safeParse({ email: formData.get('email') });
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!checkRateLimit(`forgot-password:${ip}`, 5, 15 * 60 * 1000)) {
|
||||
return { error: TOO_MANY_ATTEMPTS };
|
||||
}
|
||||
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Always report success, whether or not the account exists, so this
|
||||
// can't be used to enumerate registered emails.
|
||||
if (user) {
|
||||
const resetToken = randomBytes(32).toString('hex');
|
||||
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { resetToken, resetTokenExpiry },
|
||||
});
|
||||
await sendPasswordResetEmail({
|
||||
to: user.email,
|
||||
resetUrl: appUrl(`/reset-password?token=${resetToken}`),
|
||||
});
|
||||
}
|
||||
|
||||
return { success: 'If that email has an account, a reset link has been sent.' };
|
||||
}
|
||||
|
||||
export async function verifyEmailAction(token: string): Promise<ActionState> {
|
||||
const user = await prisma.user.findUnique({ where: { verificationToken: token } });
|
||||
if (!user || !user.verificationTokenExpiry || user.verificationTokenExpiry < new Date()) {
|
||||
return { error: 'This verification link is invalid or has expired.' };
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerified: true, verificationToken: null, verificationTokenExpiry: null },
|
||||
});
|
||||
|
||||
return { success: 'Email verified. Thanks!' };
|
||||
}
|
||||
|
||||
export async function resendVerificationEmailAction(
|
||||
_prev: ActionState,
|
||||
_formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const user = await requireUser();
|
||||
if (user.emailVerified) {
|
||||
return { success: 'Your email is already verified.' };
|
||||
}
|
||||
|
||||
if (!checkRateLimit(`resend-verification:${user.id}`, 3, 60 * 60 * 1000)) {
|
||||
return { error: TOO_MANY_ATTEMPTS };
|
||||
}
|
||||
|
||||
const verificationToken = randomBytes(32).toString('hex');
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
verificationToken,
|
||||
verificationTokenExpiry: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
await sendVerificationEmail({
|
||||
to: user.email,
|
||||
verifyUrl: appUrl(`/verify-email/${verificationToken}`),
|
||||
});
|
||||
|
||||
return { success: 'Verification email sent - check your inbox.' };
|
||||
}
|
||||
|
||||
export async function resetPasswordAction(
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const parsed = resetPasswordSchema.safeParse({
|
||||
token: formData.get('token'),
|
||||
password: formData.get('password'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const { token, password } = parsed.data;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { resetToken: token } });
|
||||
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
|
||||
return { error: 'This reset link is invalid or has expired' };
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash, resetToken: null, resetTokenExpiry: null },
|
||||
});
|
||||
|
||||
return { success: 'Password updated. You can now log in.' };
|
||||
}
|
||||
133
src/app/actions/collaborators.ts
Normal file
133
src/app/actions/collaborators.ts
Normal file
@ -0,0 +1,133 @@
|
||||
'use server';
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
import { sendCollaboratorInviteEmail, sendCollaboratorAcceptedNotification } from '@/lib/mail';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { inviteCollaboratorSchema } from '@/lib/validation';
|
||||
import { sendPushToUser } from '@/lib/push';
|
||||
|
||||
export type ActionState = { error?: string; success?: string };
|
||||
|
||||
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function inviteCollaboratorAction(
|
||||
eventId: string,
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const { user, event, isPrimaryHost } = await requireEventAccess(eventId);
|
||||
if (!isPrimaryHost) {
|
||||
return { error: 'Only the primary host can invite co-hosts' };
|
||||
}
|
||||
|
||||
const parsed = inviteCollaboratorSchema.safeParse({
|
||||
eventId,
|
||||
email: formData.get('email'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
|
||||
if (email === user.email.toLowerCase()) {
|
||||
return { error: "You're already hosting this event" };
|
||||
}
|
||||
|
||||
const existing = await prisma.eventCollaborator.findUnique({
|
||||
where: { eventId_invitedEmail: { eventId: event.id, invitedEmail: email } },
|
||||
});
|
||||
if (existing?.status === 'ACCEPTED') {
|
||||
return { error: 'That person is already a co-host' };
|
||||
}
|
||||
|
||||
const token = randomBytes(24).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + INVITE_TTL_MS);
|
||||
try {
|
||||
if (existing) {
|
||||
await prisma.eventCollaborator.update({ where: { id: existing.id }, data: { token, expiresAt } });
|
||||
} else {
|
||||
await prisma.eventCollaborator.create({
|
||||
data: { eventId: event.id, invitedEmail: email, token, expiresAt },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Concurrent double-submit can both pass the findUnique check above and
|
||||
// race on the (eventId, invitedEmail) unique constraint - the loser
|
||||
// just means someone else's invite already went out; not an error.
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||
revalidatePath(`/dashboard/events/${event.id}/guests`);
|
||||
return { success: `Invite sent to ${email}.` };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await sendCollaboratorInviteEmail({
|
||||
to: email,
|
||||
inviterName: user.name,
|
||||
eventTitle: event.title,
|
||||
acceptUrl: appUrl(`/collaborate/${token}`),
|
||||
});
|
||||
|
||||
revalidatePath(`/dashboard/events/${event.id}/guests`);
|
||||
return { success: `Invite sent to ${email}.` };
|
||||
}
|
||||
|
||||
export async function removeCollaboratorAction(collaboratorId: string) {
|
||||
const collaborator = await prisma.eventCollaborator.findUnique({ where: { id: collaboratorId } });
|
||||
if (!collaborator) notFound();
|
||||
|
||||
const { isPrimaryHost } = await requireEventAccess(collaborator.eventId);
|
||||
if (!isPrimaryHost) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
await prisma.eventCollaborator.delete({ where: { id: collaboratorId } });
|
||||
revalidatePath(`/dashboard/events/${collaborator.eventId}/guests`);
|
||||
}
|
||||
|
||||
export async function acceptCollaboratorInviteAction(token: string) {
|
||||
const user = await requireUser();
|
||||
|
||||
const collaborator = await prisma.eventCollaborator.findUnique({
|
||||
where: { token },
|
||||
include: { event: { include: { host: true } } },
|
||||
});
|
||||
if (!collaborator) notFound();
|
||||
if (collaborator.status !== 'ACCEPTED' && collaborator.expiresAt < new Date()) {
|
||||
// Expired and never accepted - treat like any other invalid token. The
|
||||
// /collaborate/[token] page itself also checks expiry up front so a
|
||||
// human hitting the link sees a clear message rather than a 404; this
|
||||
// is defense-in-depth against a direct POST to the action.
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (collaborator.status !== 'ACCEPTED') {
|
||||
await prisma.eventCollaborator.update({
|
||||
where: { id: collaborator.id },
|
||||
data: { status: 'ACCEPTED', userId: user.id, acceptedAt: new Date() },
|
||||
});
|
||||
|
||||
if (collaborator.event.host.notifyOnCoHostAccepted) {
|
||||
const manageUrl = appUrl(`/dashboard/events/${collaborator.eventId}/guests`);
|
||||
await sendCollaboratorAcceptedNotification({
|
||||
to: collaborator.event.host.email,
|
||||
collaboratorName: user.name,
|
||||
eventTitle: collaborator.event.title,
|
||||
manageUrl,
|
||||
});
|
||||
await sendPushToUser(collaborator.event.host.id, {
|
||||
title: 'Co-host invite accepted',
|
||||
body: `${user.name} is now co-hosting ${collaborator.event.title}`,
|
||||
url: manageUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
redirect(`/dashboard/events/${collaborator.eventId}/guests`);
|
||||
}
|
||||
100
src/app/actions/comments.ts
Normal file
100
src/app/actions/comments.ts
Normal file
@ -0,0 +1,100 @@
|
||||
'use server';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { accessibleEventWhere } from '@/lib/eventAccess';
|
||||
import { commentSchema } from '@/lib/validation';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
import { sendCommentHostNotification } from '@/lib/mail';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { sendPushToUser } from '@/lib/push';
|
||||
|
||||
export type ActionState = { error?: string };
|
||||
|
||||
export async function postCommentAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
|
||||
// Honeypot: real guests never fill this hidden field.
|
||||
if (formData.get('website')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = commentSchema.safeParse({
|
||||
eventId: formData.get('eventId'),
|
||||
authorName: formData.get('authorName'),
|
||||
authorEmail: formData.get('authorEmail') ?? '',
|
||||
body: formData.get('body'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rateLimited =
|
||||
!checkRateLimit(`comment:ip:${ip}`, 10, 60 * 1000) ||
|
||||
!checkRateLimit(`comment:event:${data.eventId}`, 30, 60 * 1000);
|
||||
if (rateLimited) {
|
||||
return { error: 'Too many comments. Please wait a bit and try again.' };
|
||||
}
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: data.eventId }, include: { host: true } });
|
||||
if (!event || !event.commentsEnabled) {
|
||||
return { error: 'Comments are not available for this event' };
|
||||
}
|
||||
|
||||
await prisma.comment.create({
|
||||
data: {
|
||||
eventId: event.id,
|
||||
authorName: data.authorName,
|
||||
authorEmail: data.authorEmail || '',
|
||||
body: data.body,
|
||||
},
|
||||
});
|
||||
|
||||
if (event.host.notifyOnComment) {
|
||||
const manageUrl = appUrl(`/dashboard/events/${event.id}/guests`);
|
||||
await sendCommentHostNotification({
|
||||
to: event.host.email,
|
||||
authorName: data.authorName,
|
||||
body: data.body,
|
||||
eventTitle: event.title,
|
||||
manageUrl,
|
||||
});
|
||||
await sendPushToUser(event.host.id, {
|
||||
title: 'New comment',
|
||||
body: `${data.authorName} commented on ${event.title}`,
|
||||
url: manageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/invite/${event.slug}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
async function requireOwnedComment(commentId: string) {
|
||||
const user = await requireUser();
|
||||
const comment = await prisma.comment.findUnique({ where: { id: commentId }, include: { event: true } });
|
||||
if (!comment) notFound();
|
||||
|
||||
const accessibleEvent = await prisma.event.findFirst({
|
||||
where: accessibleEventWhere(comment.eventId, user.id),
|
||||
});
|
||||
if (!accessibleEvent) notFound();
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
export async function toggleCommentHiddenAction(commentId: string) {
|
||||
const comment = await requireOwnedComment(commentId);
|
||||
await prisma.comment.update({ where: { id: comment.id }, data: { hidden: !comment.hidden } });
|
||||
revalidatePath(`/dashboard/events/${comment.eventId}/guests`);
|
||||
revalidatePath(`/invite/${comment.event.slug}`);
|
||||
}
|
||||
|
||||
export async function deleteCommentAction(commentId: string) {
|
||||
const comment = await requireOwnedComment(commentId);
|
||||
await prisma.comment.delete({ where: { id: comment.id } });
|
||||
revalidatePath(`/dashboard/events/${comment.eventId}/guests`);
|
||||
revalidatePath(`/invite/${comment.event.slug}`);
|
||||
}
|
||||
149
src/app/actions/events.ts
Normal file
149
src/app/actions/events.ts
Normal file
@ -0,0 +1,149 @@
|
||||
'use server';
|
||||
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
import { generateSlug } from '@/lib/slug';
|
||||
import { eventFormSchema } from '@/lib/validation';
|
||||
import { zonedDatetimeLocalToUtc } from '@/lib/timezone';
|
||||
import { deletePhotoFile } from '@/lib/photoStorage';
|
||||
|
||||
export type ActionState = { error?: string };
|
||||
|
||||
function parseEventForm(formData: FormData) {
|
||||
return eventFormSchema.safeParse({
|
||||
title: formData.get('title'),
|
||||
occasion: formData.get('occasion') ?? '',
|
||||
description: formData.get('description') ?? '',
|
||||
contactInfo: formData.get('contactInfo') ?? '',
|
||||
theme: formData.get('theme'),
|
||||
timezone: formData.get('timezone'),
|
||||
eventDate: formData.get('eventDate'),
|
||||
endDate: formData.get('endDate') ?? '',
|
||||
location: formData.get('location') ?? '',
|
||||
rsvpDeadline: formData.get('rsvpDeadline') ?? '',
|
||||
restrictRsvpToInvited: formData.get('restrictRsvpToInvited') === 'on',
|
||||
allowPlusOnes: formData.get('allowPlusOnes') === 'on',
|
||||
commentsEnabled: formData.get('commentsEnabled') === 'on',
|
||||
foodProvided: formData.get('foodProvided') === 'on',
|
||||
drinksProvided: formData.get('drinksProvided') === 'on',
|
||||
photosEnabled: formData.get('photosEnabled') === 'on',
|
||||
photoCloseDate: formData.get('photoCloseDate') ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
export async function createEventAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
|
||||
const user = await requireUser();
|
||||
const parsed = parseEventForm(formData);
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const data = parsed.data;
|
||||
const tz = data.timezone;
|
||||
|
||||
let slug = generateSlug();
|
||||
// Practically unreachable given the slug keyspace, but guarantee uniqueness.
|
||||
for (let attempts = 0; attempts < 5; attempts++) {
|
||||
const clash = await prisma.event.findUnique({ where: { slug } });
|
||||
if (!clash) break;
|
||||
slug = generateSlug();
|
||||
}
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
hostId: user.id,
|
||||
title: data.title,
|
||||
occasion: data.occasion,
|
||||
description: data.description,
|
||||
contactInfo: data.contactInfo,
|
||||
theme: data.theme,
|
||||
timezone: tz,
|
||||
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
|
||||
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
|
||||
location: data.location,
|
||||
rsvpDeadline: data.rsvpDeadline ? zonedDatetimeLocalToUtc(data.rsvpDeadline, tz) : null,
|
||||
restrictRsvpToInvited: data.restrictRsvpToInvited,
|
||||
allowPlusOnes: data.allowPlusOnes,
|
||||
commentsEnabled: data.commentsEnabled,
|
||||
foodProvided: data.foodProvided,
|
||||
drinksProvided: data.drinksProvided,
|
||||
photosEnabled: data.photosEnabled,
|
||||
photoCloseDate: data.photoCloseDate ? zonedDatetimeLocalToUtc(data.photoCloseDate, tz) : null,
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
redirect(`/dashboard/events/${event.id}/guests`);
|
||||
}
|
||||
|
||||
export async function updateEventAction(
|
||||
eventId: string,
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const { event } = await requireEventAccess(eventId);
|
||||
const parsed = parseEventForm(formData);
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const data = parsed.data;
|
||||
const tz = data.timezone;
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
title: data.title,
|
||||
occasion: data.occasion,
|
||||
description: data.description,
|
||||
contactInfo: data.contactInfo,
|
||||
theme: data.theme,
|
||||
timezone: tz,
|
||||
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
|
||||
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
|
||||
location: data.location,
|
||||
rsvpDeadline: data.rsvpDeadline ? zonedDatetimeLocalToUtc(data.rsvpDeadline, tz) : null,
|
||||
restrictRsvpToInvited: data.restrictRsvpToInvited,
|
||||
allowPlusOnes: data.allowPlusOnes,
|
||||
commentsEnabled: data.commentsEnabled,
|
||||
foodProvided: data.foodProvided,
|
||||
drinksProvided: data.drinksProvided,
|
||||
photosEnabled: data.photosEnabled,
|
||||
photoCloseDate: data.photoCloseDate ? zonedDatetimeLocalToUtc(data.photoCloseDate, tz) : null,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/dashboard/events/${event.id}/edit`);
|
||||
redirect(`/dashboard/events/${event.id}/guests`);
|
||||
}
|
||||
|
||||
export async function deleteEventAction(eventId: string) {
|
||||
const user = await requireUser();
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { photos: true } });
|
||||
if (!event || event.hostId !== user.id) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Photo rows cascade-delete with the event via the FK constraint, but the
|
||||
// actual files on disk don't - clean those up first or they leak forever.
|
||||
await Promise.all(event.photos.map((photo) => deletePhotoFile(event.id, photo.filename)));
|
||||
|
||||
await prisma.event.delete({ where: { id: event.id } });
|
||||
revalidatePath('/dashboard');
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
export async function regenerateSlugAction(eventId: string) {
|
||||
const { event } = await requireEventAccess(eventId);
|
||||
|
||||
let slug = generateSlug();
|
||||
for (let attempts = 0; attempts < 5; attempts++) {
|
||||
const clash = await prisma.event.findUnique({ where: { slug } });
|
||||
if (!clash) break;
|
||||
slug = generateSlug();
|
||||
}
|
||||
|
||||
await prisma.event.update({ where: { id: event.id }, data: { slug } });
|
||||
revalidatePath(`/dashboard/events/${event.id}/guests`);
|
||||
}
|
||||
101
src/app/actions/invites.ts
Normal file
101
src/app/actions/invites.ts
Normal file
@ -0,0 +1,101 @@
|
||||
'use server';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
import { sendInviteEmail } from '@/lib/mail';
|
||||
import { sendSms } from '@/lib/sms';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { sendInvitesSchema } from '@/lib/validation';
|
||||
import { parseGuestContactList } from '@/lib/emailList';
|
||||
import { checkRateLimit } from '@/lib/rateLimit';
|
||||
import { formatEventDateRange } from '@/lib/format';
|
||||
|
||||
export type ActionState = { error?: string; success?: string };
|
||||
|
||||
export async function sendInvitesAction(
|
||||
eventId: string,
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const { user, event } = await requireEventAccess(eventId);
|
||||
|
||||
const parsed = sendInvitesSchema.safeParse({
|
||||
eventId,
|
||||
emails: formData.get('emails'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
|
||||
const { emails, phones, skipped } = parseGuestContactList(parsed.data.emails);
|
||||
const totalGuests = emails.length + phones.length;
|
||||
|
||||
if (totalGuests === 0) {
|
||||
return { error: 'No valid email addresses or phone numbers found' };
|
||||
}
|
||||
|
||||
if (!checkRateLimit(`send-invites:${user.id}`, 200, 60 * 60 * 1000, totalGuests)) {
|
||||
return { error: 'Too many invites sent recently. Please try again later.' };
|
||||
}
|
||||
|
||||
const inviteUrl = appUrl(`/invite/${event.slug}`);
|
||||
let emailed = 0;
|
||||
let emailFailed = 0;
|
||||
// Being on the guest list (used to gate RSVPs when restrictRsvpToInvited
|
||||
// is on, and to route inbound SMS replies) never depends on the
|
||||
// invite actually sending - a down/misconfigured SMTP/Twilio account
|
||||
// shouldn't lock a host out of building their guest list.
|
||||
for (const email of emails) {
|
||||
const wasDelivered = await sendInviteEmail({
|
||||
to: email,
|
||||
hostName: user.name,
|
||||
eventTitle: event.title,
|
||||
eventDate: event.eventDate,
|
||||
timezone: event.timezone,
|
||||
inviteUrl,
|
||||
});
|
||||
await prisma.guestInvite.upsert({
|
||||
where: { eventId_email: { eventId: event.id, email } },
|
||||
create: { eventId: event.id, email, emailSent: wasDelivered },
|
||||
update: { sentAt: new Date(), emailSent: wasDelivered },
|
||||
});
|
||||
if (wasDelivered) emailed++;
|
||||
else emailFailed++;
|
||||
}
|
||||
|
||||
let texted = 0;
|
||||
let smsFailed = 0;
|
||||
const smsBody = `${user.name} invited you to ${event.title} on ${formatEventDateRange(event.eventDate, null, event.timezone)}. RSVP: ${inviteUrl}\nReply YES, NO, or MAYBE to this text to RSVP directly.`;
|
||||
for (const phone of phones) {
|
||||
const wasDelivered = await sendSms(phone, smsBody);
|
||||
await prisma.guestInvite.upsert({
|
||||
where: { eventId_phone: { eventId: event.id, phone } },
|
||||
create: { eventId: event.id, phone, smsSent: wasDelivered },
|
||||
update: { sentAt: new Date(), smsSent: wasDelivered },
|
||||
});
|
||||
if (wasDelivered) texted++;
|
||||
else smsFailed++;
|
||||
}
|
||||
|
||||
revalidatePath(`/dashboard/events/${event.id}/guests`);
|
||||
|
||||
const parts = [`Added ${totalGuests} guest${totalGuests === 1 ? '' : 's'} to the list.`];
|
||||
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 (skipped > 0) parts.push(`Skipped ${skipped} invalid entr${skipped === 1 ? 'y' : 'ies'}.`);
|
||||
return { success: parts.join(' ') };
|
||||
}
|
||||
|
||||
export async function removeGuestFromListAction(guestInviteId: string) {
|
||||
const guestInvite = await prisma.guestInvite.findUnique({ where: { id: guestInviteId } });
|
||||
if (!guestInvite) notFound();
|
||||
|
||||
await requireEventAccess(guestInvite.eventId);
|
||||
|
||||
await prisma.guestInvite.delete({ where: { id: guestInviteId } });
|
||||
revalidatePath(`/dashboard/events/${guestInvite.eventId}/guests`);
|
||||
}
|
||||
116
src/app/actions/photos.ts
Normal file
116
src/app/actions/photos.ts
Normal file
@ -0,0 +1,116 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||
import { savePhotoFile, deletePhotoFile, MAX_PHOTO_BYTES, ALLOWED_PHOTO_TYPES } from '@/lib/photoStorage';
|
||||
import { photoUploadSchema } from '@/lib/validation';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
class PhotoLimitReachedError extends Error {}
|
||||
|
||||
export type ActionState = { error?: string; success?: string };
|
||||
|
||||
const MAX_PHOTOS_PER_EVENT = 300;
|
||||
|
||||
export async function uploadPhotoAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
|
||||
const parsed = photoUploadSchema.safeParse({
|
||||
eventId: formData.get('eventId'),
|
||||
uploaderName: formData.get('uploaderName') ?? '',
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const { eventId, uploaderName } = parsed.data;
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rateLimited =
|
||||
!checkRateLimit(`photo:ip:${ip}`, 20, 60 * 1000) || !checkRateLimit(`photo:event:${eventId}`, 60, 60 * 1000);
|
||||
if (rateLimited) {
|
||||
return { error: 'Too many uploads. Please wait a bit and try again.' };
|
||||
}
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) {
|
||||
return { error: 'Event not found' };
|
||||
}
|
||||
if (!isPhotoUploadOpen(event)) {
|
||||
return { error: 'Photo uploads are not open for this event' };
|
||||
}
|
||||
|
||||
const file = formData.get('photo');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return { error: 'Choose a photo to upload' };
|
||||
}
|
||||
if (!ALLOWED_PHOTO_TYPES[file.type]) {
|
||||
return { error: 'Only JPEG, PNG, WebP, and GIF images are allowed' };
|
||||
}
|
||||
if (file.size > MAX_PHOTO_BYTES) {
|
||||
return { error: 'Photo is too large (8MB max)' };
|
||||
}
|
||||
|
||||
// Write the file first (cheap to redo/discard), then enforce the cap with
|
||||
// a serializable transaction so a burst of concurrent uploads can't all
|
||||
// pass a stale count check and land past MAX_PHOTOS_PER_EVENT together.
|
||||
const filename = await savePhotoFile(eventId, file);
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const photoCount = await tx.photo.count({ where: { eventId } });
|
||||
if (photoCount >= MAX_PHOTOS_PER_EVENT) {
|
||||
throw new PhotoLimitReachedError();
|
||||
}
|
||||
await tx.photo.create({ data: { eventId, filename, uploaderName } });
|
||||
},
|
||||
{ isolationLevel: 'Serializable' }
|
||||
);
|
||||
} catch (err) {
|
||||
await deletePhotoFile(eventId, filename);
|
||||
if (err instanceof PhotoLimitReachedError) {
|
||||
return { error: 'This event has reached its photo limit' };
|
||||
}
|
||||
// Serializable transaction conflict under concurrent uploads - the
|
||||
// guest just needs to retry, their file wasn't lost from their POV.
|
||||
return { error: 'Too many uploads at once - please try again.' };
|
||||
}
|
||||
|
||||
revalidatePath(`/invite/${event.slug}`);
|
||||
revalidatePath(`/dashboard/events/${eventId}/guests`);
|
||||
return { success: 'Photo uploaded! Thanks for sharing.' };
|
||||
}
|
||||
|
||||
async function requireOwnedPhoto(photoId: string) {
|
||||
const photo = await prisma.photo.findUnique({ where: { id: photoId }, include: { event: true } });
|
||||
if (!photo) return null;
|
||||
const { event } = photo;
|
||||
return { photo, event };
|
||||
}
|
||||
|
||||
export async function togglePhotoHiddenAction(photoId: string) {
|
||||
const found = await requireOwnedPhoto(photoId);
|
||||
if (!found) return;
|
||||
await requireEventAccess(found.event.id);
|
||||
|
||||
await prisma.photo.update({ where: { id: found.photo.id }, data: { hidden: !found.photo.hidden } });
|
||||
revalidatePath(`/dashboard/events/${found.event.id}/guests`);
|
||||
revalidatePath(`/invite/${found.event.slug}`);
|
||||
}
|
||||
|
||||
export async function deletePhotoAction(photoId: string) {
|
||||
const found = await requireOwnedPhoto(photoId);
|
||||
if (!found) return;
|
||||
await requireEventAccess(found.event.id);
|
||||
|
||||
await prisma.photo.delete({ where: { id: found.photo.id } });
|
||||
await deletePhotoFile(found.event.id, found.photo.filename);
|
||||
revalidatePath(`/dashboard/events/${found.event.id}/guests`);
|
||||
revalidatePath(`/invite/${found.event.slug}`);
|
||||
}
|
||||
|
||||
export async function toggleForceCloseAction(eventId: string) {
|
||||
const { event } = await requireEventAccess(eventId);
|
||||
await prisma.event.update({ where: { id: event.id }, data: { photosClosed: !event.photosClosed } });
|
||||
revalidatePath(`/dashboard/events/${event.id}/guests`);
|
||||
revalidatePath(`/invite/${event.slug}`);
|
||||
}
|
||||
30
src/app/actions/push.ts
Normal file
30
src/app/actions/push.ts
Normal file
@ -0,0 +1,30 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export async function subscribeToPushAction(subscription: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
}) {
|
||||
const user = await requireUser();
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: { endpoint: subscription.endpoint },
|
||||
create: {
|
||||
userId: user.id,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
update: {
|
||||
userId: user.id,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPushAction(endpoint: string) {
|
||||
const user = await requireUser();
|
||||
await prisma.pushSubscription.deleteMany({ where: { endpoint, userId: user.id } });
|
||||
}
|
||||
113
src/app/actions/rsvp.ts
Normal file
113
src/app/actions/rsvp.ts
Normal file
@ -0,0 +1,113 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { rsvpSchema } from '@/lib/validation';
|
||||
import { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
import { sendPushToUser } from '@/lib/push';
|
||||
|
||||
export type ActionState = { error?: string; success?: string };
|
||||
|
||||
const SUCCESS_MESSAGE = 'Thanks! Your RSVP has been recorded.';
|
||||
|
||||
export async function submitRsvpAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
|
||||
// Honeypot: a real guest never fills this hidden field. Bots that
|
||||
// blindly fill every input do, so pretend success without doing anything.
|
||||
if (formData.get('website')) {
|
||||
return { success: SUCCESS_MESSAGE };
|
||||
}
|
||||
|
||||
const parsed = rsvpSchema.safeParse({
|
||||
eventId: formData.get('eventId'),
|
||||
guestName: formData.get('guestName'),
|
||||
guestEmail: formData.get('guestEmail'),
|
||||
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;
|
||||
const guestEmail = data.guestEmail.toLowerCase();
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rateLimited =
|
||||
!checkRateLimit(`rsvp:ip:${ip}`, 20, 60 * 1000) ||
|
||||
!checkRateLimit(`rsvp:event:${data.eventId}:${guestEmail}`, 5, 60 * 60 * 1000);
|
||||
if (rateLimited) {
|
||||
return { error: 'Too many submissions. Please wait a bit and try again.' };
|
||||
}
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: data.eventId }, include: { host: true } });
|
||||
if (!event) {
|
||||
return { error: 'Event not found' };
|
||||
}
|
||||
|
||||
if (event.restrictRsvpToInvited) {
|
||||
const onGuestList = await prisma.guestInvite.findUnique({
|
||||
where: { eventId_email: { eventId: event.id, email: guestEmail } },
|
||||
});
|
||||
if (!onGuestList) {
|
||||
return {
|
||||
error:
|
||||
'This event is invite-only. Please RSVP using the email address the host invited you with, or contact them directly.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const numGuests = event.allowPlusOnes ? data.numGuests : 1;
|
||||
const existing = await prisma.rsvp.findUnique({
|
||||
where: { eventId_guestEmail: { eventId: event.id, guestEmail } },
|
||||
});
|
||||
|
||||
await prisma.rsvp.upsert({
|
||||
where: { eventId_guestEmail: { eventId: event.id, guestEmail } },
|
||||
create: {
|
||||
eventId: event.id,
|
||||
guestName: data.guestName,
|
||||
guestEmail,
|
||||
status: data.status,
|
||||
numGuests,
|
||||
note: data.note,
|
||||
},
|
||||
update: {
|
||||
guestName: data.guestName,
|
||||
status: data.status,
|
||||
numGuests,
|
||||
note: data.note,
|
||||
},
|
||||
});
|
||||
|
||||
const inviteUrl = appUrl(`/invite/${event.slug}`);
|
||||
await sendRsvpGuestConfirmation({
|
||||
to: guestEmail,
|
||||
guestName: data.guestName,
|
||||
eventTitle: event.title,
|
||||
status: data.status,
|
||||
inviteUrl,
|
||||
});
|
||||
if (event.host.notifyOnRsvp) {
|
||||
const manageUrl = appUrl(`/dashboard/events/${event.id}/guests`);
|
||||
await sendRsvpHostNotification({
|
||||
to: event.host.email,
|
||||
guestName: data.guestName,
|
||||
eventTitle: event.title,
|
||||
status: data.status,
|
||||
numGuests,
|
||||
note: data.note,
|
||||
manageUrl,
|
||||
isUpdate: Boolean(existing),
|
||||
});
|
||||
await sendPushToUser(event.host.id, {
|
||||
title: existing ? 'RSVP updated' : 'New RSVP',
|
||||
body: `${data.guestName} ${existing ? 'updated their RSVP to' : 'responded'} ${data.status} for ${event.title}`,
|
||||
url: manageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/invite/${event.slug}`);
|
||||
return { success: SUCCESS_MESSAGE };
|
||||
}
|
||||
26
src/app/actions/settings.ts
Normal file
26
src/app/actions/settings.ts
Normal file
@ -0,0 +1,26 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export type ActionState = { success?: string };
|
||||
|
||||
export async function updateNotificationPreferencesAction(
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const user = await requireUser();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
notifyOnRsvp: formData.get('notifyOnRsvp') === 'on',
|
||||
notifyOnComment: formData.get('notifyOnComment') === 'on',
|
||||
notifyOnCoHostAccepted: formData.get('notifyOnCoHostAccepted') === 'on',
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath('/dashboard/settings');
|
||||
return { success: 'Preferences saved.' };
|
||||
}
|
||||
11
src/app/api/health/route.ts
Normal file
11
src/app/api/health/route.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'error' }, { status: 503 });
|
||||
}
|
||||
}
|
||||
114
src/app/api/sms/webhook/route.ts
Normal file
114
src/app/api/sms/webhook/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { verifyTwilioSignature } from '@/lib/sms';
|
||||
import { parseSmsRsvpReply } from '@/lib/smsReply';
|
||||
import { normalizePhoneNumber } from '@/lib/phone';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { sendRsvpHostNotification } from '@/lib/mail';
|
||||
import { sendPushToUser } from '@/lib/push';
|
||||
|
||||
function twiml(message: string) {
|
||||
const escaped = message
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return new NextResponse(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escaped}</Message></Response>`,
|
||||
{ status: 200, headers: { 'Content-Type': 'text/xml' } }
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
YES: "you're in",
|
||||
NO: "you can't make it",
|
||||
MAYBE: 'you might attend',
|
||||
};
|
||||
|
||||
// Twilio POSTs application/x-www-form-urlencoded and signs the request
|
||||
// against the exact URL it was configured to call plus these form params -
|
||||
// we use APP_URL (the same value the webhook is registered with in the
|
||||
// Twilio console) rather than trusting request.url, since a reverse proxy
|
||||
// can rewrite what Next.js sees.
|
||||
export async function POST(request: NextRequest) {
|
||||
const rawBody = await request.text();
|
||||
const params = Object.fromEntries(new URLSearchParams(rawBody));
|
||||
|
||||
const signature = request.headers.get('x-twilio-signature');
|
||||
const url = appUrl('/api/sms/webhook');
|
||||
if (!verifyTwilioSignature(url, params, signature)) {
|
||||
return new NextResponse('Invalid signature', { status: 403 });
|
||||
}
|
||||
|
||||
const from = params.From;
|
||||
const body = params.Body ?? '';
|
||||
if (!from) {
|
||||
return twiml("Sorry, we couldn't read your reply.");
|
||||
}
|
||||
|
||||
const normalizedFrom = normalizePhoneNumber(from) ?? from;
|
||||
|
||||
// Guest-list membership is how an inbound text gets routed to an event at
|
||||
// all - there's one shared Twilio number for every event hosted on this
|
||||
// instance, so we match the most recent invite sent to this phone number.
|
||||
const guestInvite = await prisma.guestInvite.findFirst({
|
||||
where: { phone: normalizedFrom },
|
||||
orderBy: { sentAt: 'desc' },
|
||||
include: { event: { include: { host: true } } },
|
||||
});
|
||||
if (!guestInvite) {
|
||||
return twiml(
|
||||
"We couldn't find an invite for this number. Please use the link in your invite text, or contact the host directly."
|
||||
);
|
||||
}
|
||||
|
||||
const parsedReply = parseSmsRsvpReply(body);
|
||||
if (!parsedReply) {
|
||||
return twiml(
|
||||
'Sorry, we didn\'t understand that. Please reply YES, NO, or MAYBE (optionally with a guest count, e.g. "YES 3").'
|
||||
);
|
||||
}
|
||||
|
||||
const { event } = guestInvite;
|
||||
const numGuests = event.allowPlusOnes ? parsedReply.numGuests : 1;
|
||||
const guestName = guestInvite.name || normalizedFrom;
|
||||
|
||||
const existing = await prisma.rsvp.findUnique({
|
||||
where: { eventId_guestPhone: { eventId: event.id, guestPhone: normalizedFrom } },
|
||||
});
|
||||
|
||||
await prisma.rsvp.upsert({
|
||||
where: { eventId_guestPhone: { eventId: event.id, guestPhone: normalizedFrom } },
|
||||
create: {
|
||||
eventId: event.id,
|
||||
guestName,
|
||||
guestPhone: normalizedFrom,
|
||||
status: parsedReply.status,
|
||||
numGuests,
|
||||
},
|
||||
update: {
|
||||
guestName,
|
||||
status: parsedReply.status,
|
||||
numGuests,
|
||||
},
|
||||
});
|
||||
|
||||
if (event.host.notifyOnRsvp) {
|
||||
const manageUrl = appUrl(`/dashboard/events/${event.id}/guests`);
|
||||
await sendRsvpHostNotification({
|
||||
to: event.host.email,
|
||||
guestName,
|
||||
eventTitle: event.title,
|
||||
status: parsedReply.status,
|
||||
numGuests,
|
||||
manageUrl,
|
||||
isUpdate: Boolean(existing),
|
||||
});
|
||||
await sendPushToUser(event.host.id, {
|
||||
title: existing ? 'RSVP updated' : 'New RSVP',
|
||||
body: `${guestName} ${existing ? 'updated their RSVP to' : 'responded'} ${parsedReply.status} for ${event.title} (via text)`,
|
||||
url: manageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return twiml(`Got it - ${STATUS_LABEL[parsedReply.status]} for ${event.title}! Thanks for letting ${event.host.name} know.`);
|
||||
}
|
||||
60
src/app/collaborate/[token]/page.tsx
Normal file
60
src/app/collaborate/[token]/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getCurrentUser } from '@/lib/session';
|
||||
import { acceptCollaboratorInviteAction } from '@/app/actions/collaborators';
|
||||
|
||||
export default async function CollaborateInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
|
||||
const collaborator = await prisma.eventCollaborator.findUnique({
|
||||
where: { token },
|
||||
include: { event: { select: { id: true, title: true } } },
|
||||
});
|
||||
if (!collaborator) notFound();
|
||||
|
||||
const isExpired = collaborator.status !== 'ACCEPTED' && collaborator.expiresAt < new Date();
|
||||
const user = await getCurrentUser();
|
||||
const nextPath = `/collaborate/${token}`;
|
||||
const boundAccept = acceptCollaboratorInviteAction.bind(null, token);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Co-host invite</h1>
|
||||
<p className="mt-3 text-gray-600">
|
||||
You've been invited to help host <strong>{collaborator.event.title}</strong> — edit
|
||||
details, view RSVPs, and moderate comments together.
|
||||
</p>
|
||||
|
||||
{isExpired ? (
|
||||
<p className="mt-6 text-sm text-red-600">
|
||||
This invite link has expired. Ask the host to send you a new one.
|
||||
</p>
|
||||
) : !user ? (
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<Link
|
||||
href={`/signup?next=${encodeURIComponent(nextPath)}`}
|
||||
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
Create an account to accept
|
||||
</Link>
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent(nextPath)}`}
|
||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Log in to accept
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form action={boundAccept} className="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
Accept as {user.name}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
37
src/app/dashboard/events/[id]/edit/page.tsx
Normal file
37
src/app/dashboard/events/[id]/edit/page.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
import { EventEditor } from '@/components/EventEditor';
|
||||
import { type EventFormDefaults } from '@/components/EventForm';
|
||||
import { updateEventAction } from '@/app/actions/events';
|
||||
import { utcToZonedDatetimeLocal } from '@/lib/timezone';
|
||||
|
||||
export default async function EditEventPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { event } = await requireEventAccess(id);
|
||||
const tz = event.timezone;
|
||||
|
||||
const defaults: EventFormDefaults = {
|
||||
title: event.title,
|
||||
occasion: event.occasion,
|
||||
description: event.description,
|
||||
contactInfo: event.contactInfo,
|
||||
theme: event.theme,
|
||||
timezone: tz,
|
||||
eventDate: utcToZonedDatetimeLocal(event.eventDate, tz),
|
||||
endDate: event.endDate ? utcToZonedDatetimeLocal(event.endDate, tz) : '',
|
||||
location: event.location,
|
||||
rsvpDeadline: event.rsvpDeadline ? utcToZonedDatetimeLocal(event.rsvpDeadline, tz) : '',
|
||||
restrictRsvpToInvited: event.restrictRsvpToInvited,
|
||||
allowPlusOnes: event.allowPlusOnes,
|
||||
commentsEnabled: event.commentsEnabled,
|
||||
foodProvided: event.foodProvided,
|
||||
drinksProvided: event.drinksProvided,
|
||||
photosEnabled: event.photosEnabled,
|
||||
photoCloseDate: event.photoCloseDate ? utcToZonedDatetimeLocal(event.photoCloseDate, tz) : '',
|
||||
};
|
||||
|
||||
const boundUpdateAction = updateEventAction.bind(null, event.id);
|
||||
|
||||
return (
|
||||
<EventEditor heading="Edit invite" action={boundUpdateAction} defaults={defaults} submitLabel="Save changes" />
|
||||
);
|
||||
}
|
||||
325
src/app/dashboard/events/[id]/guests/page.tsx
Normal file
325
src/app/dashboard/events/[id]/guests/page.tsx
Normal file
@ -0,0 +1,325 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { accessibleEventWhere } from '@/lib/eventAccess';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { getTheme } from '@/lib/themes';
|
||||
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
|
||||
import { ShareLinkBox } from '@/components/ShareLinkBox';
|
||||
import { SendInvitesForm } from '@/components/SendInvitesForm';
|
||||
import { InviteCollaboratorForm } from '@/components/InviteCollaboratorForm';
|
||||
import { regenerateSlugAction, deleteEventAction } from '@/app/actions/events';
|
||||
import { sendInvitesAction, removeGuestFromListAction } from '@/app/actions/invites';
|
||||
import { toggleCommentHiddenAction, deleteCommentAction } from '@/app/actions/comments';
|
||||
import { inviteCollaboratorAction, removeCollaboratorAction } from '@/app/actions/collaborators';
|
||||
import { togglePhotoHiddenAction, deletePhotoAction, toggleForceCloseAction } from '@/app/actions/photos';
|
||||
import { formatEventDateRange } from '@/lib/format';
|
||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||
import { photoUrl } from '@/lib/photoStorage';
|
||||
|
||||
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const user = await requireUser();
|
||||
|
||||
const event = await prisma.event.findFirst({
|
||||
where: accessibleEventWhere(id, user.id),
|
||||
include: {
|
||||
rsvps: { orderBy: { createdAt: 'desc' } },
|
||||
comments: { orderBy: { createdAt: 'desc' } },
|
||||
guestInvites: { orderBy: { sentAt: 'desc' } },
|
||||
collaborators: { orderBy: { createdAt: 'asc' }, include: { user: { select: { name: true } } } },
|
||||
host: { select: { name: true, email: true } },
|
||||
photos: { orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
});
|
||||
if (!event) notFound();
|
||||
const isPrimaryHost = event.hostId === user.id;
|
||||
const photosOpen = isPhotoUploadOpen(event);
|
||||
|
||||
const theme = getTheme(event.theme);
|
||||
const inviteUrl = appUrl(`/invite/${event.slug}`);
|
||||
const tally = {
|
||||
YES: event.rsvps.filter((r) => r.status === 'YES').reduce((s, r) => s + r.numGuests, 0),
|
||||
NO: event.rsvps.filter((r) => r.status === 'NO').length,
|
||||
MAYBE: event.rsvps.filter((r) => r.status === 'MAYBE').reduce((s, r) => s + r.numGuests, 0),
|
||||
};
|
||||
|
||||
const boundRegenerate = regenerateSlugAction.bind(null, event.id);
|
||||
const boundSendInvites = sendInvitesAction.bind(null, event.id);
|
||||
const boundInviteCollaborator = inviteCollaboratorAction.bind(null, event.id);
|
||||
const boundToggleForceClose = toggleForceCloseAction.bind(null, event.id);
|
||||
|
||||
return (
|
||||
<ThemeBackground theme={theme} applyTextStyle={false} className="flex-1 p-6 sm:p-10">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||
<div className="flex flex-col gap-4 rounded-lg p-5 sm:flex-row sm:items-start sm:justify-between" style={adminCardStyle}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-2xl"
|
||||
style={{ background: theme.colors.bg, border: `2px solid ${theme.colors.accent}` }}
|
||||
>
|
||||
{theme.emoji}
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{event.title}</h1>
|
||||
<p className="text-sm text-gray-500">{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/dashboard/events/${event.id}/edit`}
|
||||
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
{isPrimaryHost && (
|
||||
<form action={deleteEventAction.bind(null, event.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<h2 className="font-semibold text-gray-900">Share link</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{event.restrictRsvpToInvited
|
||||
? 'Anyone with this link can view the invite, but only people on your guest list can RSVP.'
|
||||
: 'Anyone with this link can view the invite and RSVP.'}{' '}
|
||||
No account needed.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ShareLinkBox url={inviteUrl} onRegenerate={boundRegenerate} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<h2 className="font-semibold text-gray-900">Co-hosts</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Co-hosts can edit the invite, view RSVPs, and moderate comments, but can't delete the
|
||||
event or manage other co-hosts.
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-col gap-2 text-sm">
|
||||
<li className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
|
||||
<span>
|
||||
{event.host.name} <span className="text-gray-400">({event.host.email})</span>
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-500">Primary host</span>
|
||||
</li>
|
||||
{event.collaborators.map((collaborator) => (
|
||||
<li key={collaborator.id} className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
|
||||
<span>
|
||||
{collaborator.user?.name ?? collaborator.invitedEmail}{' '}
|
||||
<span className="text-gray-400">({collaborator.invitedEmail})</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{collaborator.status === 'ACCEPTED' ? 'Co-host' : 'Invite pending'}
|
||||
</span>
|
||||
{isPrimaryHost && (
|
||||
<form action={removeCollaboratorAction.bind(null, collaborator.id)}>
|
||||
<button type="submit" className="text-xs text-red-500 hover:text-red-700">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{isPrimaryHost && (
|
||||
<div className="mt-4">
|
||||
<InviteCollaboratorForm action={boundInviteCollaborator} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<h2 className="font-semibold text-gray-900">Guest list</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Entering an email or phone number here always adds them to your guest list, whether or
|
||||
not the invite itself successfully sends. Phone numbers get a text invite guests can
|
||||
reply YES/NO/MAYBE to directly, no account or link click needed.
|
||||
{event.restrictRsvpToInvited && ' Since RSVPs are restricted, only these people can RSVP.'}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<SendInvitesForm action={boundSendInvites} />
|
||||
</div>
|
||||
{event.restrictRsvpToInvited && event.guestInvites.length === 0 && (
|
||||
<p className="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
RSVPs are restricted to your guest list, but no one is on it yet - every RSVP attempt
|
||||
will be rejected until you add guests above.
|
||||
</p>
|
||||
)}
|
||||
{event.guestInvites.length > 0 && (
|
||||
<ul className="mt-4 flex flex-col gap-2 text-sm">
|
||||
{event.guestInvites.map((invite) => (
|
||||
<li key={invite.id} className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
|
||||
<span>{invite.email ?? invite.phone}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{invite.email
|
||||
? invite.emailSent
|
||||
? 'Emailed'
|
||||
: 'Added, not emailed'
|
||||
: invite.smsSent
|
||||
? 'Texted'
|
||||
: 'Added, not texted'}
|
||||
</span>
|
||||
<form action={removeGuestFromListAction.bind(null, invite.id)}>
|
||||
<button type="submit" className="text-xs text-red-500 hover:text-red-700">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<h2 className="font-semibold text-gray-900">
|
||||
RSVPs
|
||||
{event.restrictRsvpToInvited && (
|
||||
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
|
||||
Restricted to guest list
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="mt-2 flex gap-6 text-sm">
|
||||
<span className="text-green-700">{tally.YES} attending</span>
|
||||
<span className="text-amber-700">{tally.MAYBE} maybe</span>
|
||||
<span className="text-gray-500">{tally.NO} declined</span>
|
||||
</div>
|
||||
{event.rsvps.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-gray-500">No responses yet.</p>
|
||||
) : (
|
||||
<table className="mt-4 w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<th className="py-2 font-medium">Name</th>
|
||||
<th className="py-2 font-medium">Status</th>
|
||||
<th className="py-2 font-medium">Guests</th>
|
||||
<th className="py-2 font-medium">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{event.rsvps.map((rsvp) => (
|
||||
<tr key={rsvp.id} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<div>{rsvp.guestName}</div>
|
||||
<div className="text-xs text-gray-400">{rsvp.guestEmail}</div>
|
||||
</td>
|
||||
<td className="py-2">{rsvp.status}</td>
|
||||
<td className="py-2">{rsvp.numGuests}</td>
|
||||
<td className="py-2 text-gray-500">{rsvp.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<h2 className="font-semibold text-gray-900">Comments</h2>
|
||||
{event.comments.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-gray-500">No comments yet.</p>
|
||||
) : (
|
||||
<ul className="mt-4 flex flex-col gap-3">
|
||||
{event.comments.map((comment) => (
|
||||
<li
|
||||
key={comment.id}
|
||||
className={`rounded-md border p-3 text-sm ${comment.hidden ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
{comment.authorName} {comment.hidden && <span className="text-xs text-gray-400">(hidden)</span>}
|
||||
</p>
|
||||
<p className="mt-1 text-gray-600">{comment.body}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2 text-xs">
|
||||
<form action={toggleCommentHiddenAction.bind(null, comment.id)}>
|
||||
<button type="submit" className="text-gray-500 hover:text-gray-800">
|
||||
{comment.hidden ? 'Unhide' : 'Hide'}
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteCommentAction.bind(null, comment.id)}>
|
||||
<button type="submit" className="text-red-500 hover:text-red-700">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">Photos</h2>
|
||||
{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>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{!event.photosEnabled
|
||||
? 'Disabled for this event - enable "Let guests share photos" on the Edit page.'
|
||||
: photosOpen
|
||||
? 'Open - guests can upload photos on the invite page.'
|
||||
: event.photosClosed
|
||||
? 'Closed - you closed uploads manually.'
|
||||
: new Date() < event.eventDate
|
||||
? 'Not open yet - uploads start once the event begins.'
|
||||
: 'Closed - the photo close time has passed.'}
|
||||
</p>
|
||||
{event.photos.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-gray-500">No photos yet.</p>
|
||||
) : (
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{event.photos.map((photo) => (
|
||||
<div key={photo.id} className={`relative aspect-square overflow-hidden rounded-md ${photo.hidden ? 'opacity-40' : ''}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoUrl(event.id, photo.filename)}
|
||||
alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-2 py-1 text-xs text-white">
|
||||
<form action={togglePhotoHiddenAction.bind(null, photo.id)}>
|
||||
<button type="submit" className="hover:underline">
|
||||
{photo.hidden ? 'Unhide' : 'Hide'}
|
||||
</button>
|
||||
</form>
|
||||
<form action={deletePhotoAction.bind(null, photo.id)}>
|
||||
<button type="submit" className="text-red-300 hover:underline">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ThemeBackground>
|
||||
);
|
||||
}
|
||||
6
src/app/dashboard/events/new/page.tsx
Normal file
6
src/app/dashboard/events/new/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { EventEditor } from '@/components/EventEditor';
|
||||
import { createEventAction } from '@/app/actions/events';
|
||||
|
||||
export default function NewEventPage() {
|
||||
return <EventEditor heading="Create an invite" action={createEventAction} submitLabel="Create invite" />;
|
||||
}
|
||||
38
src/app/dashboard/layout.tsx
Normal file
38
src/app/dashboard/layout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser } from '@/lib/session';
|
||||
import { logoutAction } from '@/app/actions/auth';
|
||||
import { EmailVerificationBanner } from '@/components/EmailVerificationBanner';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gray-50">
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<Link href="/dashboard" className="text-lg font-bold text-gray-900">
|
||||
E-Invite
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link href="/dashboard/settings" className="text-gray-500 hover:text-gray-800">
|
||||
Settings
|
||||
</Link>
|
||||
<span className="text-gray-600">{user.name}</span>
|
||||
<form action={logoutAction}>
|
||||
<button type="submit" className="font-medium text-gray-500 hover:text-gray-800">
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{!user.emailVerified && <EmailVerificationBanner />}
|
||||
{/* No padding/max-width here: most pages opt into it themselves via
|
||||
PageContainer, but the themed event editor/guests pages need to
|
||||
paint their background edge-to-edge like the public invite page. */}
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/app/dashboard/page.tsx
Normal file
90
src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { getTheme } from '@/lib/themes';
|
||||
import { PageContainer } from '@/components/PageContainer';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser();
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
OR: [{ hostId: user.id }, { collaborators: { some: { userId: user.id, status: 'ACCEPTED' } } }],
|
||||
},
|
||||
orderBy: { eventDate: 'asc' },
|
||||
include: {
|
||||
_count: { select: { comments: true } },
|
||||
rsvps: { select: { status: true, numGuests: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Your events</h1>
|
||||
<Link
|
||||
href="/dashboard/events/new"
|
||||
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
New event
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className="mt-8 text-gray-600">
|
||||
You haven't created any events yet.{' '}
|
||||
<Link href="/dashboard/events/new" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Create your first invite
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-6 flex flex-col gap-3">
|
||||
{events.map((event) => {
|
||||
const theme = getTheme(event.theme);
|
||||
const yesCount = event.rsvps
|
||||
.filter((r) => r.status === 'YES')
|
||||
.reduce((sum, r) => sum + r.numGuests, 0);
|
||||
return (
|
||||
<li key={event.id}>
|
||||
<Link
|
||||
href={`/dashboard/events/${event.id}/guests`}
|
||||
className="flex items-center justify-between rounded-lg border bg-white px-5 py-4 hover:border-indigo-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-lg"
|
||||
style={{ background: theme.colors.bg }}
|
||||
>
|
||||
{theme.emoji}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{event.title}
|
||||
{event.hostId !== user.id && (
|
||||
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
|
||||
Co-hosting
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{event.eventDate.toLocaleDateString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeZone: event.timezone,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500">
|
||||
<p>{yesCount} attending</p>
|
||||
<p>{event._count.comments} comments</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
38
src/app/dashboard/settings/page.tsx
Normal file
38
src/app/dashboard/settings/page.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { PageContainer } from '@/components/PageContainer';
|
||||
import { NotificationPreferencesForm } from '@/components/NotificationPreferencesForm';
|
||||
import { PushNotificationToggle } from '@/components/PushNotificationToggle';
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const user = await requireUser();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
|
||||
<div className="mt-6 flex max-w-md flex-col gap-6">
|
||||
<div className="rounded-lg border bg-white p-6">
|
||||
<h2 className="font-semibold text-gray-900">Email notifications</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Choose which emails you get as a host. This applies to events you host directly - not
|
||||
ones you're co-hosting.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<NotificationPreferencesForm
|
||||
notifyOnRsvp={user.notifyOnRsvp}
|
||||
notifyOnComment={user.notifyOnComment}
|
||||
notifyOnCoHostAccepted={user.notifyOnCoHostAccepted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-white p-6">
|
||||
<h2 className="font-semibold text-gray-900">Browser push notifications</h2>
|
||||
<div className="mt-4">
|
||||
<PushNotificationToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
26
src/app/error.tsx
Normal file
26
src/app/error.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
export default function GlobalError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Something went wrong</h1>
|
||||
<p className="text-gray-600">
|
||||
Sorry about that - an unexpected error occurred. You can try again, or head back home.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-md bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-md border border-gray-300 px-4 py-2 font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Go home
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
73
src/app/globals.css
Normal file
73
src/app/globals.css
Normal file
@ -0,0 +1,73 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--theme-bg: #fafafa;
|
||||
--theme-surface: #ffffff;
|
||||
--theme-primary: #111827;
|
||||
--theme-accent: #2563eb;
|
||||
--theme-text: #111827;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #111827;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(var(--confetti-fall-distance, 220px)) rotate(var(--confetti-spin, 300deg));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
animation: fade-in-up 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.pop-in {
|
||||
animation: pop-in 0.35s ease-out;
|
||||
}
|
||||
|
||||
.confetti-piece {
|
||||
animation: confetti-fall var(--confetti-duration, 1.1s) ease-in forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal,
|
||||
.pop-in,
|
||||
.confetti-piece {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
145
src/app/invite/[slug]/page.tsx
Normal file
145
src/app/invite/[slug]/page.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getTheme } from '@/lib/themes';
|
||||
import { ThemedInviteLayout } from '@/components/ThemedInviteLayout';
|
||||
import { ThemeDivider } from '@/components/ThemeDivider';
|
||||
import { themeCardStyle as cardStyle } from '@/components/ThemeBackground';
|
||||
import { RsvpForm } from '@/components/RsvpForm';
|
||||
import { CommentForm } from '@/components/CommentForm';
|
||||
import { PhotoUploadForm } from '@/components/PhotoUploadForm';
|
||||
import { PhotoGallery } from '@/components/PhotoGallery';
|
||||
import { formatEventDateRange } from '@/lib/format';
|
||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||
import { photoUrl } from '@/lib/photoStorage';
|
||||
|
||||
export default async function PublicInvitePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
host: { select: { name: true } },
|
||||
comments: { where: { hidden: false }, orderBy: { createdAt: 'desc' } },
|
||||
photos: { where: { hidden: false }, orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
});
|
||||
if (!event) notFound();
|
||||
|
||||
const theme = getTheme(event.theme);
|
||||
const rsvpClosed = Boolean(event.rsvpDeadline && event.rsvpDeadline < new Date());
|
||||
const photosOpen = isPhotoUploadOpen(event);
|
||||
const showPhotos = event.photosEnabled && (photosOpen || event.photos.length > 0);
|
||||
|
||||
return (
|
||||
<ThemedInviteLayout theme={theme}>
|
||||
<div className="reveal rounded-lg p-6 text-center sm:p-8" style={{ ...cardStyle, animationDelay: '0s' }}>
|
||||
<div
|
||||
className="mx-auto flex h-20 w-20 items-center justify-center rounded-full text-4xl"
|
||||
style={{ background: 'var(--theme-bg)', border: '2px solid var(--theme-accent)' }}
|
||||
>
|
||||
{theme.emoji}
|
||||
</div>
|
||||
<p className="mt-4 text-sm uppercase tracking-wide opacity-70">You're invited by {event.host.name}</p>
|
||||
<h1 className="mt-2 break-words text-3xl font-bold sm:text-4xl">{event.title}</h1>
|
||||
{event.occasion && <p className="mt-1 italic opacity-80">{event.occasion}</p>}
|
||||
<p className="mt-3 text-lg">{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}</p>
|
||||
{event.location && <p className="mt-1 opacity-80">{event.location}</p>}
|
||||
{(event.foodProvided || event.drinksProvided) && (
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-2">
|
||||
{event.foodProvided && (
|
||||
<span
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
style={{ background: 'var(--theme-bg)', border: '1px solid var(--theme-accent)' }}
|
||||
>
|
||||
🍽️ Food will be provided
|
||||
</span>
|
||||
)}
|
||||
{event.drinksProvided && (
|
||||
<span
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
style={{ background: 'var(--theme-bg)', border: '1px solid var(--theme-accent)' }}
|
||||
>
|
||||
🍹 Drinks will be provided
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ThemeDivider theme={theme} />
|
||||
|
||||
{event.description && (
|
||||
<div
|
||||
className="reveal mt-6 whitespace-pre-wrap rounded-lg p-6 text-center"
|
||||
style={{ ...cardStyle, animationDelay: '0.1s' }}
|
||||
>
|
||||
{event.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="reveal mt-10 rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.2s' }}>
|
||||
<h2 className="text-xl font-semibold">RSVP</h2>
|
||||
{event.restrictRsvpToInvited && (
|
||||
<p className="mt-1 text-xs opacity-60">This event is invite-only.</p>
|
||||
)}
|
||||
{rsvpClosed ? (
|
||||
<p className="mt-3 text-sm opacity-70">RSVPs are closed for this event.</p>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<RsvpForm eventId={event.id} allowPlusOnes={event.allowPlusOnes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.commentsEnabled && (
|
||||
<>
|
||||
<ThemeDivider theme={theme} />
|
||||
<div className="reveal rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.3s' }}>
|
||||
<h2 className="text-xl font-semibold">Well wishes</h2>
|
||||
<div className="mt-4">
|
||||
<CommentForm eventId={event.id} />
|
||||
</div>
|
||||
|
||||
{event.comments.length > 0 && (
|
||||
<ul className="mt-6 flex flex-col gap-4">
|
||||
{event.comments.map((comment) => (
|
||||
<li key={comment.id} className="border-t pt-4 first:border-0 first:pt-0" style={{ borderColor: 'var(--theme-accent)' }}>
|
||||
<p className="font-medium">{comment.authorName}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap opacity-90">{comment.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showPhotos && (
|
||||
<>
|
||||
<ThemeDivider theme={theme} />
|
||||
<div className="reveal rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.4s' }}>
|
||||
<h2 className="text-xl font-semibold">Photos</h2>
|
||||
{photosOpen ? (
|
||||
<div className="mt-4">
|
||||
<PhotoUploadForm eventId={event.id} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm opacity-70">Photo uploads are closed for this event.</p>
|
||||
)}
|
||||
<PhotoGallery
|
||||
photos={event.photos.map((p) => ({
|
||||
id: p.id,
|
||||
url: photoUrl(event.id, p.filename),
|
||||
uploaderName: p.uploaderName,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{event.contactInfo && (
|
||||
<p className="mt-8 text-center text-sm opacity-70">{event.contactInfo}</p>
|
||||
)}
|
||||
</ThemedInviteLayout>
|
||||
);
|
||||
}
|
||||
16
src/app/layout.tsx
Normal file
16
src/app/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { allFontVariables } from '@/lib/fonts';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'E-Invite',
|
||||
description: 'Create and share themed online invitations',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={allFontVariables}>
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
18
src/app/not-found.tsx
Normal file
18
src/app/not-found.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Page not found</h1>
|
||||
<p className="text-gray-600">
|
||||
The page or invite you're looking for doesn't exist, or the link may have been regenerated.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
55
src/app/page.tsx
Normal file
55
src/app/page.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import Link from 'next/link';
|
||||
import { getCurrentUser } from '@/lib/session';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { THEMES } from '@/lib/themes';
|
||||
|
||||
export default async function HomePage() {
|
||||
const user = await getCurrentUser();
|
||||
if (user) redirect('/dashboard');
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center gap-8 px-6 py-16 text-center">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-gray-900">E-Invite</h1>
|
||||
<p className="mt-3 text-lg text-gray-600">
|
||||
Create beautiful themed online invitations, collect RSVPs, and share a single link
|
||||
with everyone you're inviting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="rounded-md bg-indigo-600 px-5 py-2.5 font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-md border border-gray-300 px-5 py-2.5 font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{THEMES.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex flex-col items-center gap-1 rounded-lg border px-4 py-3 text-sm"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bg,
|
||||
backgroundImage: theme.pattern.backgroundImage,
|
||||
backgroundSize: theme.pattern.backgroundSize,
|
||||
color: theme.colors.text,
|
||||
borderColor: theme.colors.accent,
|
||||
}}
|
||||
>
|
||||
<span className="text-xl">{theme.emoji}</span>
|
||||
{theme.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
21
src/app/verify-email/[token]/page.tsx
Normal file
21
src/app/verify-email/[token]/page.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import Link from 'next/link';
|
||||
import { verifyEmailAction } from '@/app/actions/auth';
|
||||
|
||||
export default async function VerifyEmailPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const result = await verifyEmailAction(token);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Email verification</h1>
|
||||
{result.success ? (
|
||||
<p className="mt-3 text-green-700">{result.success}</p>
|
||||
) : (
|
||||
<p className="mt-3 text-red-600">{result.error}</p>
|
||||
)}
|
||||
<Link href="/dashboard" className="mt-6 font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Go to dashboard
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
48
src/components/CommentForm.tsx
Normal file
48
src/components/CommentForm.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { postCommentAction, type ActionState } from '@/app/actions/comments';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
import { Honeypot } from '@/components/Honeypot';
|
||||
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export function CommentForm({ eventId }: { eventId: string }) {
|
||||
const [state, formAction] = useActionState(postCommentAction, initialState);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<Honeypot />
|
||||
<input type="hidden" name="eventId" value={eventId} />
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
name="authorName"
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
required
|
||||
className="rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
name="authorEmail"
|
||||
type="email"
|
||||
placeholder="Email (optional)"
|
||||
className="rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
name="body"
|
||||
rows={3}
|
||||
placeholder="Leave a message for the host..."
|
||||
required
|
||||
className="rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
|
||||
/>
|
||||
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
|
||||
<SubmitButton
|
||||
className="self-start rounded-md px-5 py-2 font-medium text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--theme-primary)' }}
|
||||
>
|
||||
Post comment
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
43
src/components/ConfettiBurst.tsx
Normal file
43
src/components/ConfettiBurst.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
const COLORS = ['#e0218a', '#ffb703', '#2563eb', '#3f6b3a', '#9333ea', '#00e5ff'];
|
||||
const PIECES = 18;
|
||||
|
||||
/** A one-shot burst of falling confetti pieces, purely CSS-animated. Meant
|
||||
* to be mounted once (e.g. alongside a success message) and left in place -
|
||||
* the animation runs once and pieces end fully transparent. */
|
||||
export function ConfettiBurst() {
|
||||
const pieces = Array.from({ length: PIECES }, (_, i) => {
|
||||
const left = Math.random() * 100;
|
||||
const delay = Math.random() * 0.25;
|
||||
const duration = 0.9 + Math.random() * 0.6;
|
||||
const fallDistance = 160 + Math.random() * 120;
|
||||
const spin = (Math.random() > 0.5 ? 1 : -1) * (200 + Math.random() * 250);
|
||||
const color = COLORS[i % COLORS.length];
|
||||
const size = 6 + Math.random() * 5;
|
||||
return { left, delay, duration, fallDistance, spin, color, size, key: i };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-visible" aria-hidden="true">
|
||||
{pieces.map((p) => (
|
||||
<span
|
||||
key={p.key}
|
||||
className="confetti-piece absolute top-0 block rounded-sm"
|
||||
style={
|
||||
{
|
||||
left: `${p.left}%`,
|
||||
width: p.size,
|
||||
height: p.size * 0.6,
|
||||
backgroundColor: p.color,
|
||||
animationDelay: `${p.delay}s`,
|
||||
'--confetti-duration': `${p.duration}s`,
|
||||
'--confetti-fall-distance': `${p.fallDistance}px`,
|
||||
'--confetti-spin': `${p.spin}deg`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/EmailVerificationBanner.tsx
Normal file
30
src/components/EmailVerificationBanner.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { resendVerificationEmailAction } from '@/app/actions/auth';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
type ActionState = { error?: string; success?: string };
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export function EmailVerificationBanner() {
|
||||
const [state, formAction] = useActionState(resendVerificationEmailAction, initialState);
|
||||
|
||||
if (state.success) {
|
||||
return (
|
||||
<div className="border-b border-green-200 bg-green-50 px-6 py-2 text-center text-sm text-green-800">
|
||||
{state.success}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 border-b border-amber-200 bg-amber-50 px-6 py-2 text-sm text-amber-800 sm:flex-row">
|
||||
<span>Please verify your email address.</span>
|
||||
<form action={formAction}>
|
||||
<SubmitButton className="font-medium underline hover:no-underline">Resend verification email</SubmitButton>
|
||||
</form>
|
||||
{state.error && <span className="text-red-700">{state.error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/components/EventEditor.tsx
Normal file
32
src/components/EventEditor.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { getTheme, DEFAULT_THEME_ID } from '@/lib/themes';
|
||||
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
|
||||
import { EventForm, type EventActionState, type EventFormDefaults } from '@/components/EventForm';
|
||||
|
||||
export function EventEditor({
|
||||
heading,
|
||||
action,
|
||||
defaults,
|
||||
submitLabel,
|
||||
}: {
|
||||
heading: string;
|
||||
action: (state: EventActionState, formData: FormData) => Promise<EventActionState>;
|
||||
defaults?: EventFormDefaults;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
const [themeId, setThemeId] = useState(defaults?.theme ?? DEFAULT_THEME_ID);
|
||||
const theme = getTheme(themeId);
|
||||
|
||||
return (
|
||||
<ThemeBackground theme={theme} applyTextStyle={false} className="flex-1 p-6 sm:p-10">
|
||||
<div className="mx-auto max-w-2xl rounded-lg p-6" style={adminCardStyle}>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
|
||||
<div className="mt-6">
|
||||
<EventForm action={action} defaults={defaults} submitLabel={submitLabel} onThemeChange={setThemeId} />
|
||||
</div>
|
||||
</div>
|
||||
</ThemeBackground>
|
||||
);
|
||||
}
|
||||
273
src/components/EventForm.tsx
Normal file
273
src/components/EventForm.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react';
|
||||
import { ThemePicker } from '@/components/ThemePicker';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
import { DEFAULT_THEME_ID } from '@/lib/themes';
|
||||
import { TIMEZONES } from '@/lib/timezone';
|
||||
|
||||
export type EventActionState = { error?: string };
|
||||
|
||||
export type EventFormDefaults = {
|
||||
title: string;
|
||||
occasion: string;
|
||||
description: string;
|
||||
contactInfo: string;
|
||||
theme: string;
|
||||
timezone: string;
|
||||
eventDate: string;
|
||||
endDate: string;
|
||||
location: string;
|
||||
rsvpDeadline: string;
|
||||
restrictRsvpToInvited: boolean;
|
||||
allowPlusOnes: boolean;
|
||||
commentsEnabled: boolean;
|
||||
foodProvided: boolean;
|
||||
drinksProvided: boolean;
|
||||
photosEnabled: boolean;
|
||||
photoCloseDate: string;
|
||||
};
|
||||
|
||||
const emptyDefaults: EventFormDefaults = {
|
||||
title: '',
|
||||
occasion: '',
|
||||
description: '',
|
||||
contactInfo: '',
|
||||
theme: DEFAULT_THEME_ID,
|
||||
timezone: '',
|
||||
eventDate: '',
|
||||
endDate: '',
|
||||
location: '',
|
||||
rsvpDeadline: '',
|
||||
restrictRsvpToInvited: false,
|
||||
allowPlusOnes: true,
|
||||
commentsEnabled: true,
|
||||
foodProvided: false,
|
||||
drinksProvided: false,
|
||||
photosEnabled: true,
|
||||
photoCloseDate: '',
|
||||
};
|
||||
|
||||
export function EventForm({
|
||||
action,
|
||||
defaults = emptyDefaults,
|
||||
submitLabel,
|
||||
onThemeChange,
|
||||
}: {
|
||||
action: (state: EventActionState, formData: FormData) => Promise<EventActionState>;
|
||||
defaults?: EventFormDefaults;
|
||||
submitLabel: string;
|
||||
onThemeChange?: (themeId: string) => void;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(action, {});
|
||||
// New events (empty default) auto-detect the browser's zone; edits keep
|
||||
// whatever was saved.
|
||||
const [timezone, setTimezone] = useState(defaults.timezone);
|
||||
useEffect(() => {
|
||||
if (!defaults.timezone) {
|
||||
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
}
|
||||
}, [defaults.timezone]);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-6">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||
Event title
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={defaults.title}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="occasion" className="block text-sm font-medium text-gray-700">
|
||||
Who or what is this for? (optional)
|
||||
</label>
|
||||
<input
|
||||
id="occasion"
|
||||
name="occasion"
|
||||
type="text"
|
||||
placeholder="e.g. Jane's 30th Birthday, The Smith Family Reunion"
|
||||
defaultValue={defaults.occasion}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
defaultValue={defaults.description}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
|
||||
Start date & time
|
||||
</label>
|
||||
<input
|
||||
id="eventDate"
|
||||
name="eventDate"
|
||||
type="datetime-local"
|
||||
required
|
||||
defaultValue={defaults.eventDate}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="endDate" className="block text-sm font-medium text-gray-700">
|
||||
End date & time (optional)
|
||||
</label>
|
||||
<input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
defaultValue={defaults.endDate}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
id="timezone"
|
||||
name="timezone"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
>
|
||||
{!timezone && <option value="">Select a timezone…</option>}
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
All times on this invite (start, end, RSVP deadline, photo window) are interpreted in
|
||||
this timezone, regardless of where a guest views it from.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
id="location"
|
||||
name="location"
|
||||
type="text"
|
||||
defaultValue={defaults.location}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contactInfo" className="block text-sm font-medium text-gray-700">
|
||||
Contact info for questions (optional)
|
||||
</label>
|
||||
<input
|
||||
id="contactInfo"
|
||||
name="contactInfo"
|
||||
type="text"
|
||||
placeholder="e.g. Text Chris at 555-1234 with any questions"
|
||||
defaultValue={defaults.contactInfo}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="rsvpDeadline" className="block text-sm font-medium text-gray-700">
|
||||
RSVP deadline (optional)
|
||||
</label>
|
||||
<input
|
||||
id="rsvpDeadline"
|
||||
name="rsvpDeadline"
|
||||
type="datetime-local"
|
||||
defaultValue={defaults.rsvpDeadline}
|
||||
className="mt-1 w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="photoCloseDate" className="block text-sm font-medium text-gray-700">
|
||||
Photo uploads close at (optional)
|
||||
</label>
|
||||
<input
|
||||
id="photoCloseDate"
|
||||
name="photoCloseDate"
|
||||
type="datetime-local"
|
||||
defaultValue={defaults.photoCloseDate}
|
||||
className="mt-1 w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Guests can share photos once the event starts. Leave blank to keep uploads open
|
||||
indefinitely (you can also close them manually at any time).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-700">Theme</span>
|
||||
<div className="mt-2">
|
||||
<ThemePicker defaultThemeId={defaults.theme} onChange={onThemeChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="restrictRsvpToInvited"
|
||||
defaultChecked={defaults.restrictRsvpToInvited}
|
||||
/>
|
||||
Only let people on my guest list RSVP
|
||||
</label>
|
||||
<p className="-mt-1 ml-6 text-xs text-gray-500">
|
||||
Uses the guest list you build on the guest-management page (adding someone there always
|
||||
adds them to the list, whether or not the invite email sends).
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="allowPlusOnes" defaultChecked={defaults.allowPlusOnes} />
|
||||
Allow guests to bring plus-ones
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="commentsEnabled" defaultChecked={defaults.commentsEnabled} />
|
||||
Allow comments / well-wishes
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="foodProvided" defaultChecked={defaults.foodProvided} />
|
||||
Food will be available
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="drinksProvided" defaultChecked={defaults.drinksProvided} />
|
||||
Drinks will be available
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="photosEnabled" defaultChecked={defaults.photosEnabled} />
|
||||
Let guests share photos during/after the event
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
|
||||
<SubmitButton className="rounded-md bg-indigo-600 px-5 py-2.5 font-medium text-white hover:bg-indigo-500 disabled:opacity-60 sm:self-start">
|
||||
{submitLabel}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
10
src/components/Honeypot.tsx
Normal file
10
src/components/Honeypot.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
// Hidden field bots tend to fill blindly; humans never see or fill it.
|
||||
// Server actions treat a non-empty value as a bot and silently no-op.
|
||||
export function Honeypot() {
|
||||
return (
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px' }}>
|
||||
<label htmlFor="website">Leave this field empty</label>
|
||||
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/InviteCollaboratorForm.tsx
Normal file
36
src/components/InviteCollaboratorForm.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
import type { ActionState } from '@/app/actions/collaborators';
|
||||
|
||||
export function InviteCollaboratorForm({
|
||||
action,
|
||||
}: {
|
||||
action: (state: ActionState, formData: FormData) => Promise<ActionState>;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(action, {});
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="collaborator-email" className="text-sm font-medium text-gray-700">
|
||||
Co-host's email
|
||||
</label>
|
||||
<input
|
||||
id="collaborator-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="friend@example.com"
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
|
||||
Send invite
|
||||
</SubmitButton>
|
||||
{state.error && <p className="text-sm text-red-600 sm:basis-full">{state.error}</p>}
|
||||
{state.success && <p className="text-sm text-green-700 sm:basis-full">{state.success}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
42
src/components/NotificationPreferencesForm.tsx
Normal file
42
src/components/NotificationPreferencesForm.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { updateNotificationPreferencesAction, type ActionState } from '@/app/actions/settings';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export function NotificationPreferencesForm({
|
||||
notifyOnRsvp,
|
||||
notifyOnComment,
|
||||
notifyOnCoHostAccepted,
|
||||
}: {
|
||||
notifyOnRsvp: boolean;
|
||||
notifyOnComment: boolean;
|
||||
notifyOnCoHostAccepted: boolean;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(updateNotificationPreferencesAction, initialState);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="notifyOnRsvp" defaultChecked={notifyOnRsvp} />
|
||||
Email me when a guest RSVPs
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="notifyOnComment" defaultChecked={notifyOnComment} />
|
||||
Email me when a guest leaves a comment
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="notifyOnCoHostAccepted" defaultChecked={notifyOnCoHostAccepted} />
|
||||
Email me when someone accepts a co-host invite
|
||||
</label>
|
||||
|
||||
{state.success && <p className="pop-in 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">
|
||||
Save preferences
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
3
src/components/PageContainer.tsx
Normal file
3
src/components/PageContainer.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function PageContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mx-auto w-full max-w-4xl px-6 py-8">{children}</div>;
|
||||
}
|
||||
28
src/components/PhotoGallery.tsx
Normal file
28
src/components/PhotoGallery.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
type GalleryPhoto = {
|
||||
id: string;
|
||||
url: string;
|
||||
uploaderName: string;
|
||||
};
|
||||
|
||||
export function PhotoGallery({ photos }: { photos: GalleryPhoto[] }) {
|
||||
if (photos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.id} className="group relative aspect-square overflow-hidden rounded-md">
|
||||
{/* Guest-uploaded photos aren't known ahead of time, so next/image's
|
||||
static optimization doesn't gain much here - plain <img> keeps
|
||||
this simple. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo.url} alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'} className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105" loading="lazy" />
|
||||
{photo.uploaderName && (
|
||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{photo.uploaderName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/PhotoUploadForm.tsx
Normal file
44
src/components/PhotoUploadForm.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { uploadPhotoAction, type ActionState } from '@/app/actions/photos';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export function PhotoUploadForm({ eventId }: { eventId: string }) {
|
||||
const [state, formAction] = useActionState(uploadPhotoAction, initialState);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<input type="hidden" name="eventId" value={eventId} />
|
||||
<div>
|
||||
<label htmlFor="photo" className="block text-sm font-medium">
|
||||
Add a photo
|
||||
</label>
|
||||
<input
|
||||
id="photo"
|
||||
name="photo"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
name="uploaderName"
|
||||
type="text"
|
||||
placeholder="Your name (optional)"
|
||||
className="rounded-md border border-black/10 bg-white/90 px-3 py-2 text-sm text-gray-900 focus:outline-none"
|
||||
/>
|
||||
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
|
||||
{state.success && <p className="pop-in text-sm text-green-700">{state.success}</p>}
|
||||
<SubmitButton
|
||||
className="self-start rounded-md px-5 py-2 font-medium text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--theme-primary)' }}
|
||||
>
|
||||
Upload
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
94
src/components/PushNotificationToggle.tsx
Normal file
94
src/components/PushNotificationToggle.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { subscribeToPushAction, unsubscribeFromPushAction } from '@/app/actions/push';
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
|
||||
export function PushNotificationToggle() {
|
||||
const [supported, setSupported] = useState(false);
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !VAPID_PUBLIC_KEY) return;
|
||||
setSupported(true);
|
||||
navigator.serviceWorker.register('/sw.js').then(async (registration) => {
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
setSubscribed(!!existing);
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function enable() {
|
||||
setStatus('');
|
||||
if (!VAPID_PUBLIC_KEY) {
|
||||
setStatus('Push notifications are not configured on this server.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
setStatus('Notification permission was denied.');
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||
});
|
||||
const json = subscription.toJSON();
|
||||
await subscribeToPushAction({
|
||||
endpoint: json.endpoint!,
|
||||
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||
});
|
||||
setSubscribed(true);
|
||||
setStatus('Push notifications enabled on this device.');
|
||||
} catch {
|
||||
setStatus('Could not enable push notifications on this device.');
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
await unsubscribeFromPushAction(subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
setSubscribed(false);
|
||||
setStatus('Push notifications disabled on this device.');
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
Browser push notifications aren't available (unsupported browser, or not configured on
|
||||
this server).
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Get a browser notification on this device for the same events as your email preferences
|
||||
above. This is per-device - enable it separately on each browser you use.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={subscribed ? disable : enable}
|
||||
className="self-start rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-transform duration-150 hover:scale-[1.02] hover:bg-gray-50 active:scale-95"
|
||||
>
|
||||
{subscribed ? 'Disable push notifications on this device' : 'Enable push notifications on this device'}
|
||||
</button>
|
||||
{status && <p className="pop-in text-sm text-gray-600">{status}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/components/RsvpForm.tsx
Normal file
113
src/components/RsvpForm.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
import { submitRsvpAction, type ActionState } from '@/app/actions/rsvp';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
import { Honeypot } from '@/components/Honeypot';
|
||||
import { ConfettiBurst } from '@/components/ConfettiBurst';
|
||||
|
||||
const initialState: ActionState = {};
|
||||
|
||||
export function RsvpForm({ eventId, allowPlusOnes }: { eventId: string; allowPlusOnes: boolean }) {
|
||||
const [state, formAction] = useActionState(submitRsvpAction, initialState);
|
||||
const [status, setStatus] = useState<'YES' | 'NO' | 'MAYBE'>('YES');
|
||||
|
||||
if (state.success) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{status !== 'NO' && <ConfettiBurst />}
|
||||
<p className="pop-in rounded-md bg-green-50 px-4 py-3 text-sm text-green-800">{state.success}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
<Honeypot />
|
||||
<input type="hidden" name="eventId" value={eventId} />
|
||||
|
||||
<div>
|
||||
<label htmlFor="guestName" className="block text-sm font-medium">
|
||||
Your name
|
||||
</label>
|
||||
<input
|
||||
id="guestName"
|
||||
name="guestName"
|
||||
type="text"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="guestEmail" className="block text-sm font-medium">
|
||||
Your email
|
||||
</label>
|
||||
<input
|
||||
id="guestEmail"
|
||||
name="guestEmail"
|
||||
type="email"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="block text-sm font-medium">Will you attend?</span>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{(['YES', 'MAYBE', 'NO'] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setStatus(option)}
|
||||
className={`rounded-md border px-4 py-2 text-sm font-medium transition-all duration-150 hover:-translate-y-0.5 active:translate-y-0 ${
|
||||
status === option ? 'pop-in border-current bg-white/90 text-gray-900' : 'border-black/10 bg-white/50'
|
||||
}`}
|
||||
>
|
||||
{option === 'YES' ? 'Yes' : option === 'NO' ? "Can't make it" : 'Maybe'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input type="hidden" name="status" value={status} />
|
||||
</div>
|
||||
|
||||
{allowPlusOnes && status !== 'NO' && (
|
||||
<div>
|
||||
<label htmlFor="numGuests" className="block text-sm font-medium">
|
||||
Number of guests (including you)
|
||||
</label>
|
||||
<input
|
||||
id="numGuests"
|
||||
name="numGuests"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
defaultValue={1}
|
||||
className="mt-1 w-24 rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="note" className="block text-sm font-medium">
|
||||
Message to the host (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="note"
|
||||
name="note"
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-gray-900 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
|
||||
|
||||
<SubmitButton
|
||||
className="self-start rounded-md px-5 py-2.5 font-medium text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--theme-primary)' }}
|
||||
>
|
||||
Submit RSVP
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
33
src/components/SendInvitesForm.tsx
Normal file
33
src/components/SendInvitesForm.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
import type { ActionState } from '@/app/actions/invites';
|
||||
|
||||
export function SendInvitesForm({
|
||||
action,
|
||||
}: {
|
||||
action: (state: ActionState, formData: FormData) => Promise<ActionState>;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(action, {});
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<label htmlFor="emails" className="text-sm font-medium text-gray-700">
|
||||
Guest emails
|
||||
</label>
|
||||
<textarea
|
||||
id="emails"
|
||||
name="emails"
|
||||
rows={3}
|
||||
placeholder="one@example.com, two@example.com"
|
||||
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 invites
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
71
src/components/ShareLinkBox.tsx
Normal file
71
src/components/ShareLinkBox.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ShareLinkBox({
|
||||
url,
|
||||
onRegenerate,
|
||||
}: {
|
||||
url: string;
|
||||
onRegenerate: () => Promise<void>;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="flex-1 truncate rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{confirming ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600">Old link will stop working. Are you sure?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
onRegenerate();
|
||||
}}
|
||||
className="font-medium text-red-600 hover:text-red-500"
|
||||
>
|
||||
Yes, regenerate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="self-start text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Regenerate link
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/SubmitButton.tsx
Normal file
30
src/components/SubmitButton.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
const INTERACTIVE = 'transition-transform duration-150 hover:scale-[1.03] active:scale-95';
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
style={style}
|
||||
className={`${INTERACTIVE} ${
|
||||
className ??
|
||||
'w-full rounded-md bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-500 disabled:opacity-60'
|
||||
} ${pending ? 'scale-100' : ''}`}
|
||||
>
|
||||
{pending ? 'Please wait…' : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
58
src/components/ThemeBackground.tsx
Normal file
58
src/components/ThemeBackground.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import type { Theme } from '@/lib/themes';
|
||||
import { fontFamilyForTheme } from '@/lib/fonts';
|
||||
|
||||
/**
|
||||
* Sets the theme's CSS custom properties (--theme-*) plus its background
|
||||
* pattern on a wrapping element. `applyTextStyle` controls whether the
|
||||
* theme's text color/font are also applied - the public invite page wants
|
||||
* that everywhere, while the host-facing dashboard pages only want the
|
||||
* background/accent color, keeping admin UI text in the normal readable
|
||||
* sans-serif regardless of which decorative font the event uses.
|
||||
*/
|
||||
export function ThemeBackground({
|
||||
theme,
|
||||
className,
|
||||
applyTextStyle = true,
|
||||
children,
|
||||
}: {
|
||||
theme: Theme;
|
||||
className?: string;
|
||||
applyTextStyle?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const style = {
|
||||
'--theme-bg': theme.colors.bg,
|
||||
'--theme-surface': theme.colors.surface,
|
||||
'--theme-primary': theme.colors.primary,
|
||||
'--theme-accent': theme.colors.accent,
|
||||
'--theme-text': theme.colors.text,
|
||||
backgroundColor: theme.colors.bg,
|
||||
backgroundImage: theme.pattern.backgroundImage,
|
||||
backgroundSize: theme.pattern.backgroundSize,
|
||||
...(applyTextStyle ? { color: theme.colors.text, fontFamily: fontFamilyForTheme(theme.id) } : {}),
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div style={style} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For the public invite page, which fully adopts the theme (including text
|
||||
// color chosen to contrast with a possibly-dark surface).
|
||||
export const themeCardStyle: React.CSSProperties = {
|
||||
background: 'var(--theme-surface)',
|
||||
borderTop: '4px solid var(--theme-accent)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||
};
|
||||
|
||||
// For host-facing admin pages (event editor, guest management): always a
|
||||
// plain white card so the normal dark-gray form/table text stays readable
|
||||
// no matter how dark a theme's surface color is. Only the accent border
|
||||
// carries the theme through.
|
||||
export const adminCardStyle: React.CSSProperties = {
|
||||
background: '#ffffff',
|
||||
borderTop: '4px solid var(--theme-accent)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||
};
|
||||
11
src/components/ThemeDivider.tsx
Normal file
11
src/components/ThemeDivider.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import type { Theme } from '@/lib/themes';
|
||||
|
||||
export function ThemeDivider({ theme }: { theme: Theme }) {
|
||||
return (
|
||||
<div className="my-2 flex items-center justify-center gap-3" aria-hidden="true">
|
||||
<span className="h-px w-16" style={{ backgroundColor: 'var(--theme-accent)', opacity: 0.5 }} />
|
||||
<span className="text-lg leading-none">{theme.emoji}</span>
|
||||
<span className="h-px w-16" style={{ backgroundColor: 'var(--theme-accent)', opacity: 0.5 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/ThemePicker.tsx
Normal file
53
src/components/ThemePicker.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { THEMES } from '@/lib/themes';
|
||||
|
||||
export function ThemePicker({
|
||||
defaultThemeId,
|
||||
onChange,
|
||||
}: {
|
||||
defaultThemeId: string;
|
||||
onChange?: (themeId: string) => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(defaultThemeId);
|
||||
|
||||
function select(themeId: string) {
|
||||
setSelected(themeId);
|
||||
onChange?.(themeId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="hidden" name="theme" value={selected} />
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{THEMES.map((theme) => {
|
||||
const isSelected = theme.id === selected;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={theme.id}
|
||||
onClick={() => select(theme.id)}
|
||||
className={`flex flex-col items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 ${isSelected ? 'pop-in' : ''}`}
|
||||
style={{
|
||||
backgroundColor: theme.colors.bg,
|
||||
backgroundImage: theme.pattern.backgroundImage,
|
||||
backgroundSize: theme.pattern.backgroundSize,
|
||||
color: theme.colors.text,
|
||||
borderColor: isSelected ? theme.colors.primary : 'transparent',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-xl transition-transform duration-200"
|
||||
style={{ background: theme.colors.surface, border: `1px solid ${theme.colors.accent}` }}
|
||||
>
|
||||
{theme.emoji}
|
||||
</span>
|
||||
{theme.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/components/ThemedInviteLayout.tsx
Normal file
10
src/components/ThemedInviteLayout.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import type { Theme } from '@/lib/themes';
|
||||
import { ThemeBackground } from '@/components/ThemeBackground';
|
||||
|
||||
export function ThemedInviteLayout({ theme, children }: { theme: Theme; children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeBackground theme={theme} className="min-h-screen">
|
||||
<div className="mx-auto max-w-2xl px-6 py-16">{children}</div>
|
||||
</ThemeBackground>
|
||||
);
|
||||
}
|
||||
57
src/lib/emailList.test.ts
Normal file
57
src/lib/emailList.test.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseEmailList, parseGuestContactList } from '@/lib/emailList';
|
||||
|
||||
describe('parseEmailList', () => {
|
||||
it('parses comma and newline separated emails', () => {
|
||||
const { validEmails, skipped } = parseEmailList('a@example.com, b@example.com\nc@example.com');
|
||||
expect(validEmails.sort()).toEqual(['a@example.com', 'b@example.com', 'c@example.com']);
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
|
||||
it('lowercases and deduplicates addresses', () => {
|
||||
const { validEmails, skipped } = parseEmailList('A@Example.com, a@example.com');
|
||||
expect(validEmails).toEqual(['a@example.com']);
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
|
||||
it('counts invalid entries as skipped without failing the whole batch', () => {
|
||||
const { validEmails, skipped } = parseEmailList('good@example.com, not-an-email, ,also bad');
|
||||
expect(validEmails).toEqual(['good@example.com']);
|
||||
expect(skipped).toBe(2);
|
||||
});
|
||||
|
||||
it('returns an empty list for blank input', () => {
|
||||
const { validEmails, skipped } = parseEmailList(' \n , ');
|
||||
expect(validEmails).toEqual([]);
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGuestContactList', () => {
|
||||
it('routes entries with an @ to emails and everything else to phones', () => {
|
||||
const result = parseGuestContactList('a@example.com, 202-555-0134');
|
||||
expect(result.emails).toEqual(['a@example.com']);
|
||||
expect(result.phones).toEqual(['+12025550134']);
|
||||
expect(result.skipped).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts a mix of several emails and phones across lines', () => {
|
||||
const result = parseGuestContactList('one@example.com\n202-555-0134\ntwo@example.com\n(202) 555-0199');
|
||||
expect(result.emails.sort()).toEqual(['one@example.com', 'two@example.com']);
|
||||
expect(result.phones.sort()).toEqual(['+12025550134', '+12025550199']);
|
||||
expect(result.skipped).toBe(0);
|
||||
});
|
||||
|
||||
it('skips invalid emails and invalid phone numbers separately', () => {
|
||||
const result = parseGuestContactList('not-an-email, also bad, good@example.com');
|
||||
expect(result.emails).toEqual(['good@example.com']);
|
||||
expect(result.phones).toEqual([]);
|
||||
expect(result.skipped).toBe(2);
|
||||
});
|
||||
|
||||
it('deduplicates and lowercases emails, and normalizes duplicate phone formats', () => {
|
||||
const result = parseGuestContactList('A@Example.com, a@example.com, 2025550134, 202-555-0134');
|
||||
expect(result.emails).toEqual(['a@example.com']);
|
||||
expect(result.phones).toEqual(['+12025550134']);
|
||||
});
|
||||
});
|
||||
77
src/lib/emailList.ts
Normal file
77
src/lib/emailList.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
import { looksLikeEmail, normalizePhoneNumber } from '@/lib/phone';
|
||||
|
||||
const emailSchema = z.string().trim().email();
|
||||
|
||||
export type ParsedEmailList = {
|
||||
validEmails: string[];
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a free-form textarea of guest emails (comma and/or newline
|
||||
* separated) into a deduplicated, lowercased list of valid addresses, plus
|
||||
* a count of entries that didn't parse as a valid email.
|
||||
*/
|
||||
export function parseEmailList(raw: string): ParsedEmailList {
|
||||
const rawEntries = raw
|
||||
.split(/[\n,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const validEmails = new Set<string>();
|
||||
let skipped = 0;
|
||||
for (const entry of rawEntries) {
|
||||
const result = emailSchema.safeParse(entry);
|
||||
if (result.success) {
|
||||
validEmails.add(result.data.toLowerCase());
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { validEmails: [...validEmails], skipped };
|
||||
}
|
||||
|
||||
export type ParsedGuestContactList = {
|
||||
emails: string[];
|
||||
phones: string[];
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Like parseEmailList, but for a guest list textarea that accepts a mix of
|
||||
* emails and phone numbers - each entry is routed by whether it contains an
|
||||
* '@', then validated/normalized accordingly. Anything that fails either
|
||||
* check is counted as skipped rather than rejecting the whole batch.
|
||||
*/
|
||||
export function parseGuestContactList(raw: string): ParsedGuestContactList {
|
||||
const rawEntries = raw
|
||||
.split(/[\n,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const emails = new Set<string>();
|
||||
const phones = new Set<string>();
|
||||
let skipped = 0;
|
||||
|
||||
for (const entry of rawEntries) {
|
||||
if (looksLikeEmail(entry)) {
|
||||
const result = emailSchema.safeParse(entry);
|
||||
if (result.success) {
|
||||
emails.add(result.data.toLowerCase());
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} else {
|
||||
const normalized = normalizePhoneNumber(entry);
|
||||
if (normalized) {
|
||||
phones.add(normalized);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { emails: [...emails], phones: [...phones], skipped };
|
||||
}
|
||||
31
src/lib/eventAccess.ts
Normal file
31
src/lib/eventAccess.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
/**
|
||||
* Prisma `where` clause matching an event a given user can manage: either
|
||||
* they're the primary host, or an accepted collaborator. Exported so pages
|
||||
* that need to `include` relations alongside the access check (RSVPs,
|
||||
* comments, ...) can compose it into their own query instead of doing a
|
||||
* separate lookup.
|
||||
*/
|
||||
export function accessibleEventWhere(eventId: string, userId: string) {
|
||||
return {
|
||||
id: eventId,
|
||||
OR: [{ hostId: userId }, { collaborators: { some: { userId, status: 'ACCEPTED' as const } } }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an event the current user may manage (primary host or accepted
|
||||
* collaborator), 404ing otherwise. `isPrimaryHost` gates the handful of
|
||||
* actions reserved for the original host - deleting the event and managing
|
||||
* who else has access - everything else (edit details, RSVPs, comments,
|
||||
* sending invites) is available to any accepted collaborator too.
|
||||
*/
|
||||
export async function requireEventAccess(eventId: string) {
|
||||
const user = await requireUser();
|
||||
const event = await prisma.event.findFirst({ where: accessibleEventWhere(eventId, user.id) });
|
||||
if (!event) notFound();
|
||||
return { user, event, isPrimaryHost: event.hostId === user.id };
|
||||
}
|
||||
51
src/lib/fonts.ts
Normal file
51
src/lib/fonts.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import {
|
||||
Playfair_Display,
|
||||
Baloo_2,
|
||||
Quicksand,
|
||||
Inter,
|
||||
Mountains_of_Christmas,
|
||||
Special_Elite,
|
||||
Press_Start_2P,
|
||||
} from 'next/font/google';
|
||||
|
||||
const playfairDisplay = Playfair_Display({ subsets: ['latin'], variable: '--font-classic-elegant' });
|
||||
const baloo2 = Baloo_2({ subsets: ['latin'], variable: '--font-birthday-confetti' });
|
||||
const quicksandGarden = Quicksand({ subsets: ['latin'], variable: '--font-garden-party' });
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-modern-minimal' });
|
||||
const mountainsOfChristmas = Mountains_of_Christmas({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '700'],
|
||||
variable: '--font-holiday-festive',
|
||||
});
|
||||
const quicksandBaby = Quicksand({ subsets: ['latin'], variable: '--font-baby-shower-pastel' });
|
||||
const specialElite = Special_Elite({ subsets: ['latin'], weight: '400', variable: '--font-goth-grunge' });
|
||||
const pressStart2p = Press_Start_2P({ subsets: ['latin'], weight: '400', variable: '--font-retro-arcade' });
|
||||
|
||||
// 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
|
||||
// combined class list to apply on the root layout.
|
||||
export const allFontVariables = [
|
||||
playfairDisplay.variable,
|
||||
baloo2.variable,
|
||||
quicksandGarden.variable,
|
||||
inter.variable,
|
||||
mountainsOfChristmas.variable,
|
||||
quicksandBaby.variable,
|
||||
specialElite.variable,
|
||||
pressStart2p.variable,
|
||||
].join(' ');
|
||||
|
||||
const FONT_FAMILY_BY_THEME: Record<string, string> = {
|
||||
'classic-elegant': 'var(--font-classic-elegant)',
|
||||
'birthday-confetti': 'var(--font-birthday-confetti)',
|
||||
'garden-party': 'var(--font-garden-party)',
|
||||
'modern-minimal': 'var(--font-modern-minimal)',
|
||||
'holiday-festive': 'var(--font-holiday-festive)',
|
||||
'baby-shower-pastel': 'var(--font-baby-shower-pastel)',
|
||||
'goth-grunge': 'var(--font-goth-grunge)',
|
||||
'retro-arcade': 'var(--font-retro-arcade)',
|
||||
};
|
||||
|
||||
export function fontFamilyForTheme(themeId: string) {
|
||||
return FONT_FAMILY_BY_THEME[themeId] ?? FONT_FAMILY_BY_THEME['classic-elegant'];
|
||||
}
|
||||
33
src/lib/format.test.ts
Normal file
33
src/lib/format.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatEventDateRange } from '@/lib/format';
|
||||
|
||||
describe('formatEventDateRange', () => {
|
||||
it('formats a start-only date with no range, in the given timezone', () => {
|
||||
const start = new Date('2027-07-18T16:00:00Z'); // noon Eastern (EDT, UTC-4)
|
||||
const result = formatEventDateRange(start, null, 'America/New_York');
|
||||
expect(result).toBe('Sunday, July 18, 2027 at 12:00 PM');
|
||||
});
|
||||
|
||||
it('shows just the end time for a same-day range in the event timezone', () => {
|
||||
const start = new Date('2027-07-18T16:00:00Z'); // noon Eastern
|
||||
const end = new Date('2027-07-18T19:00:00Z'); // 3pm Eastern
|
||||
const result = formatEventDateRange(start, end, 'America/New_York');
|
||||
expect(result).toBe('Sunday, July 18, 2027 at 12:00 PM – 3:00 PM');
|
||||
});
|
||||
|
||||
it('shows the full end date for a multi-day range', () => {
|
||||
const start = new Date('2027-07-18T16:00:00Z');
|
||||
const end = new Date('2027-07-19T14:00:00Z');
|
||||
const result = formatEventDateRange(start, end, 'America/New_York');
|
||||
expect(result.match(/2027/g)?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('determines "same day" using the event timezone, not the server/UTC day', () => {
|
||||
// 9pm Pacific on the 18th is already 4am UTC on the 19th - a naive
|
||||
// UTC-based day comparison would wrongly call this a multi-day range.
|
||||
const start = new Date('2027-07-19T04:00:00Z'); // 9:00 PM Jul 18 Pacific (PDT, UTC-7)
|
||||
const end = new Date('2027-07-19T05:30:00Z'); // 10:30 PM Jul 18 Pacific
|
||||
const result = formatEventDateRange(start, end, 'America/Los_Angeles');
|
||||
expect(result).toBe('Sunday, July 18, 2027 at 9:00 PM – 10:30 PM');
|
||||
});
|
||||
});
|
||||
16
src/lib/format.ts
Normal file
16
src/lib/format.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/** "Saturday, July 18, 2026 at 12:00 PM" or, with an end time on the same
|
||||
* day, "... at 12:00 PM – 3:00 PM"; a multi-day end gets its own full date.
|
||||
* Always formatted in the event's own timezone, not the viewer's or
|
||||
* server's - two people in different timezones should see the same wording. */
|
||||
export function formatEventDateRange(start: Date, end: Date | null, timeZone: string): string {
|
||||
const startStr = start.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short', timeZone });
|
||||
if (!end) return startStr;
|
||||
|
||||
const dayKey = (d: Date) => d.toLocaleDateString('en-US', { timeZone });
|
||||
const sameDay = dayKey(start) === dayKey(end);
|
||||
const endStr = end.toLocaleString(
|
||||
'en-US',
|
||||
sameDay ? { timeStyle: 'short', timeZone } : { dateStyle: 'full', timeStyle: 'short', timeZone }
|
||||
);
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
29
src/lib/mail.test.ts
Normal file
29
src/lib/mail.test.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { escapeHtml, stripNewlines } from '@/lib/mail';
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes HTML-significant characters', () => {
|
||||
expect(escapeHtml('<script>alert("hi")</script>')).toBe(
|
||||
'<script>alert("hi")</script>'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes ampersands and single quotes', () => {
|
||||
expect(escapeHtml("Rock & Roll's back")).toBe('Rock & Roll's back');
|
||||
});
|
||||
|
||||
it('leaves plain text untouched', () => {
|
||||
expect(escapeHtml('Birthday Bash 2027')).toBe('Birthday Bash 2027');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripNewlines', () => {
|
||||
it('collapses newlines into a single space and trims', () => {
|
||||
expect(stripNewlines(' Hello\nWorld\r\n! ')).toBe('Hello World !');
|
||||
});
|
||||
|
||||
it('prevents header injection via a fake extra header line', () => {
|
||||
const malicious = 'Subject\r\nBcc: victim@example.com';
|
||||
expect(stripNewlines(malicious)).not.toMatch(/[\r\n]/);
|
||||
});
|
||||
});
|
||||
242
src/lib/mail.ts
Normal file
242
src/lib/mail.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import 'server-only';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export function stripNewlines(value: string) {
|
||||
return value.replace(/[\r\n]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT ?? 1025),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Returns whether the send succeeded rather than throwing. A guest's RSVP or
|
||||
// comment is already persisted by the time we try to email a confirmation,
|
||||
// so a flaky/misconfigured SMTP server shouldn't turn that into a 500 for
|
||||
// them - we log it server-side and let the caller decide how to react.
|
||||
async function sendMail(opts: { to: string; subject: string; html: string }): Promise<boolean> {
|
||||
const from = process.env.SMTP_FROM ?? 'E-Invite <invites@example.com>';
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from,
|
||||
to: opts.to,
|
||||
subject: stripNewlines(opts.subject),
|
||||
html: opts.html,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to send email to ${opts.to}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function layout(title: string, bodyHtml: string) {
|
||||
return `
|
||||
<div style="font-family: -apple-system, Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px; color: #1f2937;">
|
||||
<h1 style="font-size: 20px; margin-bottom: 16px;">${title}</h1>
|
||||
${bodyHtml}
|
||||
<p style="margin-top: 32px; font-size: 12px; color: #9ca3af;">Sent by E-Invite</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function sendInviteEmail(opts: {
|
||||
to: string;
|
||||
guestName?: string;
|
||||
hostName: string;
|
||||
eventTitle: string;
|
||||
eventDate: Date;
|
||||
timezone: 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,';
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: `You're invited: ${opts.eventTitle}`,
|
||||
html: layout(
|
||||
`You're invited to ${eventTitle}!`,
|
||||
`
|
||||
<p>${greeting}</p>
|
||||
<p>${hostName} has invited you to <strong>${eventTitle}</strong> on
|
||||
${opts.eventDate.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short', timeZone: opts.timezone })}.</p>
|
||||
<p><a href="${opts.inviteUrl}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;">View invitation & RSVP</a></p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendRsvpGuestConfirmation(opts: {
|
||||
to: string;
|
||||
guestName: string;
|
||||
eventTitle: string;
|
||||
status: 'YES' | 'NO' | 'MAYBE';
|
||||
inviteUrl: string;
|
||||
}): Promise<boolean> {
|
||||
const statusText = { YES: "you're going", NO: "you can't make it", MAYBE: 'you might attend' }[
|
||||
opts.status
|
||||
];
|
||||
const guestName = escapeHtml(stripNewlines(opts.guestName));
|
||||
const eventTitle = escapeHtml(opts.eventTitle);
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: `RSVP received: ${opts.eventTitle}`,
|
||||
html: layout(
|
||||
'Your RSVP has been received',
|
||||
`
|
||||
<p>Hi ${guestName},</p>
|
||||
<p>Thanks for responding to <strong>${eventTitle}</strong>. We've recorded that ${statusText}.</p>
|
||||
<p><a href="${opts.inviteUrl}">View the invitation again</a></p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendRsvpHostNotification(opts: {
|
||||
to: string;
|
||||
guestName: string;
|
||||
eventTitle: string;
|
||||
status: 'YES' | 'NO' | 'MAYBE';
|
||||
numGuests: number;
|
||||
note?: string;
|
||||
manageUrl: string;
|
||||
isUpdate?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const guestName = escapeHtml(stripNewlines(opts.guestName));
|
||||
const eventTitle = escapeHtml(opts.eventTitle);
|
||||
const note = opts.note ? escapeHtml(opts.note) : '';
|
||||
const verb = opts.isUpdate ? 'updated their RSVP to' : 'responded';
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: opts.isUpdate ? `RSVP updated for ${opts.eventTitle}` : `New RSVP for ${opts.eventTitle}`,
|
||||
html: layout(
|
||||
opts.isUpdate ? 'RSVP updated' : 'New RSVP',
|
||||
`
|
||||
<p><strong>${guestName}</strong> ${verb} <strong>${opts.status}</strong>
|
||||
(${opts.numGuests} guest${opts.numGuests === 1 ? '' : 's'}) to <strong>${eventTitle}</strong>.</p>
|
||||
${note ? `<p>Note: ${note}</p>` : ''}
|
||||
<p><a href="${opts.manageUrl}">Manage guests</a></p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendCommentHostNotification(opts: {
|
||||
to: string;
|
||||
authorName: string;
|
||||
body: string;
|
||||
eventTitle: string;
|
||||
manageUrl: string;
|
||||
}): Promise<boolean> {
|
||||
const authorName = escapeHtml(stripNewlines(opts.authorName));
|
||||
const eventTitle = escapeHtml(opts.eventTitle);
|
||||
const body = escapeHtml(opts.body);
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: `New comment on ${opts.eventTitle}`,
|
||||
html: layout(
|
||||
'New comment',
|
||||
`
|
||||
<p><strong>${authorName}</strong> left a comment on <strong>${eventTitle}</strong>:</p>
|
||||
<p style="padding:12px;background:#f3f4f6;border-radius:6px;">${body}</p>
|
||||
<p><a href="${opts.manageUrl}">Manage comments</a></p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendCollaboratorAcceptedNotification(opts: {
|
||||
to: string;
|
||||
collaboratorName: string;
|
||||
eventTitle: string;
|
||||
manageUrl: string;
|
||||
}): Promise<boolean> {
|
||||
const collaboratorName = escapeHtml(stripNewlines(opts.collaboratorName));
|
||||
const eventTitle = escapeHtml(opts.eventTitle);
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: `${opts.collaboratorName} accepted your co-host invite`,
|
||||
html: layout(
|
||||
'Co-host invite accepted',
|
||||
`
|
||||
<p><strong>${collaboratorName}</strong> is now co-hosting <strong>${eventTitle}</strong> with you.</p>
|
||||
<p><a href="${opts.manageUrl}">Manage the event</a></p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendCollaboratorInviteEmail(opts: {
|
||||
to: string;
|
||||
inviterName: string;
|
||||
eventTitle: string;
|
||||
acceptUrl: string;
|
||||
}): Promise<boolean> {
|
||||
const inviterName = escapeHtml(stripNewlines(opts.inviterName));
|
||||
const eventTitle = escapeHtml(opts.eventTitle);
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: `You've been invited to co-host ${opts.eventTitle}`,
|
||||
html: layout(
|
||||
"You've been invited to co-host an event",
|
||||
`
|
||||
<p>${inviterName} invited you to help manage <strong>${eventTitle}</strong> on E-Invite -
|
||||
you'll be able to edit the invite, see RSVPs, and moderate comments together.</p>
|
||||
<p><a href="${opts.acceptUrl}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;">Accept invite</a></p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(opts: { to: string; verifyUrl: string }): Promise<boolean> {
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: 'Verify your email',
|
||||
html: layout(
|
||||
'Verify your email',
|
||||
`
|
||||
<p>Thanks for signing up for E-Invite! Click below to verify your email address.</p>
|
||||
<p><a href="${opts.verifyUrl}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;">Verify email</a></p>
|
||||
<p>This link expires in 24 hours. You can keep using your account either way - this just
|
||||
confirms we can reach you.</p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(opts: { to: string; resetUrl: string }): Promise<boolean> {
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: 'Reset your password',
|
||||
html: layout(
|
||||
'Reset your password',
|
||||
`
|
||||
<p>Click the link below to reset your E-Invite password. This link expires in 1 hour.</p>
|
||||
<p><a href="${opts.resetUrl}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;">Reset password</a></p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
129
src/lib/patterns.ts
Normal file
129
src/lib/patterns.ts
Normal file
@ -0,0 +1,129 @@
|
||||
// Tileable inline-SVG background patterns for theme decoration. Everything
|
||||
// here is generated as a data: URI so there are no external image assets to
|
||||
// ship - the Docker image stays fully self-contained.
|
||||
|
||||
function svgBackground(svg: string, size: string) {
|
||||
const uri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
return { backgroundImage: uri, backgroundSize: size };
|
||||
}
|
||||
|
||||
/** Elegant diamond lattice - fine gold lines on a light card. */
|
||||
export function damaskPattern(color: string) {
|
||||
const size = 64;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g fill="none" stroke="${color}" stroke-width="1" opacity="0.35">
|
||||
<path d="M32 2 L62 32 L32 62 L2 32 Z"/>
|
||||
<circle cx="32" cy="32" r="3" fill="${color}"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered confetti rectangles/circles/triangles in a few colors. */
|
||||
export function confettiPattern(colors: string[]) {
|
||||
const size = 140;
|
||||
const [a, b, c] = colors;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g opacity="0.55">
|
||||
<rect x="14" y="18" width="10" height="4" fill="${a}" transform="rotate(20 19 20)"/>
|
||||
<circle cx="52" cy="30" r="4" fill="${b}"/>
|
||||
<rect x="88" y="14" width="8" height="8" fill="${c}" transform="rotate(45 92 18)"/>
|
||||
<path d="M110 60 l6 10 l-12 0 z" fill="${a}"/>
|
||||
<circle cx="20" cy="76" r="3" fill="${c}"/>
|
||||
<rect x="60" y="82" width="10" height="4" fill="${b}" transform="rotate(-15 65 84)"/>
|
||||
<circle cx="100" cy="100" r="4" fill="${a}"/>
|
||||
<rect x="30" y="110" width="8" height="8" fill="${c}" transform="rotate(30 34 114)"/>
|
||||
<path d="M124 100 l6 10 l-12 0 z" fill="${b}"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Simple polka-dot grid. */
|
||||
export function polkaDotPattern(color: string, radius = 2.5) {
|
||||
const size = 36;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<circle cx="${size / 2}" cy="${size / 2}" r="${radius}" fill="${color}" opacity="0.4"/>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered leaf/vine motifs for a garden theme. */
|
||||
export function leafPattern(leaf: string, accent: string) {
|
||||
const size = 150;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g opacity="0.5" fill="none" stroke="${leaf}" stroke-width="2">
|
||||
<path d="M20 30 Q30 15 45 25 Q30 35 20 30 Z" fill="${leaf}" stroke="none"/>
|
||||
<path d="M100 20 Q108 8 120 15 Q110 25 100 20 Z" fill="${accent}" stroke="none"/>
|
||||
<path d="M70 70 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
|
||||
<path d="M20 100 Q30 85 45 95 Q30 105 20 100 Z" fill="${accent}" stroke="none"/>
|
||||
<path d="M110 110 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
|
||||
<path d="M60 10 C 60 40, 60 40, 60 55" stroke="${leaf}" stroke-width="1.5"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered 6-point snowflakes for a winter/holiday theme. */
|
||||
export function snowflakePattern(color: string) {
|
||||
const size = 120;
|
||||
const flake = (cx: number, cy: number, r: number) => `
|
||||
<g transform="translate(${cx} ${cy})" stroke="${color}" stroke-width="1.4" opacity="0.6">
|
||||
${[0, 60, 120].map((deg) => `<line x1="${-r}" y1="0" x2="${r}" y2="0" transform="rotate(${deg})"/>`).join('')}
|
||||
</g>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
${flake(20, 25, 7)}
|
||||
${flake(85, 15, 5)}
|
||||
${flake(60, 70, 9)}
|
||||
${flake(105, 95, 6)}
|
||||
${flake(15, 95, 5)}
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered soft 4-point sparkle stars for a pastel theme. */
|
||||
export function starPattern(colors: string[]) {
|
||||
const size = 130;
|
||||
const [a, b] = colors;
|
||||
const star = (cx: number, cy: number, r: number, fill: string) =>
|
||||
`<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 svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g opacity="0.6">
|
||||
${star(25, 30, 7, a)}
|
||||
${star(95, 20, 5, b)}
|
||||
${star(60, 65, 6, a)}
|
||||
${star(110, 90, 8, b)}
|
||||
${star(20, 100, 5, a)}
|
||||
</g>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered claw-mark scratches for a grunge/distressed theme. */
|
||||
export function scratchPattern(color: string) {
|
||||
const size = 130;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g stroke="${color}" stroke-width="1.5" opacity="0.35" stroke-linecap="round">
|
||||
<line x1="10" y1="15" x2="34" y2="55"/>
|
||||
<line x1="18" y1="10" x2="40" y2="48"/>
|
||||
<line x1="26" y1="8" x2="46" y2="42"/>
|
||||
<line x1="85" y1="75" x2="110" y2="115"/>
|
||||
<line x1="93" y1="70" x2="115" y2="108"/>
|
||||
<line x1="60" y1="20" x2="72" y2="38"/>
|
||||
<line x1="15" y1="90" x2="28" y2="112"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Subtle pixel-grid with scattered neon "pixels" for a retro arcade theme. */
|
||||
export function pixelGridPattern(gridColor: string, pixelColors: string[]) {
|
||||
const size = 40;
|
||||
const [a, b] = pixelColors;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<rect width="${size}" height="${size}" fill="none" stroke="${gridColor}" stroke-width="1" opacity="0.25"/>
|
||||
<rect x="4" y="4" width="5" height="5" fill="${a}" opacity="0.6"/>
|
||||
<rect x="26" y="20" width="5" height="5" fill="${b}" opacity="0.6"/>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
45
src/lib/phone.test.ts
Normal file
45
src/lib/phone.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizePhoneNumber, looksLikeEmail } from '@/lib/phone';
|
||||
|
||||
describe('normalizePhoneNumber', () => {
|
||||
// 202-555-01XX is NANP's officially reserved fictional-use range (real
|
||||
// area code, "555" exchange) - "555" itself is not an assigned area code,
|
||||
// so it can't be used alone the way fiction often depicts it.
|
||||
it('normalizes a 10-digit US number to E.164', () => {
|
||||
expect(normalizePhoneNumber('202-555-0134')).toBe('+12025550134');
|
||||
});
|
||||
|
||||
it('normalizes a US number with various punctuation', () => {
|
||||
expect(normalizePhoneNumber('(202) 555-0134')).toBe('+12025550134');
|
||||
});
|
||||
|
||||
it('accepts an already-E.164 number', () => {
|
||||
expect(normalizePhoneNumber('+12025550134')).toBe('+12025550134');
|
||||
});
|
||||
|
||||
it('normalizes an international number given its country code', () => {
|
||||
expect(normalizePhoneNumber('+44 20 7946 0958')).toBe('+442079460958');
|
||||
});
|
||||
|
||||
it('returns null for garbage input', () => {
|
||||
expect(normalizePhoneNumber('not a phone number')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a too-short number', () => {
|
||||
expect(normalizePhoneNumber('12345')).toBeNull();
|
||||
});
|
||||
|
||||
it('respects a non-default country', () => {
|
||||
expect(normalizePhoneNumber('20 7946 0958', 'GB')).toBe('+442079460958');
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeEmail', () => {
|
||||
it('treats anything with an @ as an email attempt', () => {
|
||||
expect(looksLikeEmail('person@example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats anything without an @ as not an email', () => {
|
||||
expect(looksLikeEmail('555-123-4567')).toBe(false);
|
||||
});
|
||||
});
|
||||
16
src/lib/phone.ts
Normal file
16
src/lib/phone.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
|
||||
|
||||
/** Normalizes a free-form phone number to E.164 (e.g. "+15551234567"),
|
||||
* assuming `defaultCountry` for numbers with no country code. Returns null
|
||||
* for anything that doesn't parse as a valid, real phone number. */
|
||||
export function normalizePhoneNumber(raw: string, defaultCountry: CountryCode = 'US'): string | null {
|
||||
const phone = parsePhoneNumberFromString(raw, defaultCountry);
|
||||
if (!phone || !phone.isValid()) return null;
|
||||
return phone.format('E.164');
|
||||
}
|
||||
|
||||
/** A quick heuristic for routing a guest-list entry: anything with an '@'
|
||||
* is treated as an email attempt, everything else as a phone attempt. */
|
||||
export function looksLikeEmail(value: string): boolean {
|
||||
return value.includes('@');
|
||||
}
|
||||
51
src/lib/photoStorage.ts
Normal file
51
src/lib/photoStorage.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import 'server-only';
|
||||
import { mkdir, writeFile, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const MAX_PHOTO_BYTES = 8 * 1024 * 1024; // 8MB
|
||||
export const ALLOWED_PHOTO_TYPES: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
const UPLOADS_ROOT = path.join(process.cwd(), 'public', 'uploads');
|
||||
|
||||
function eventDir(eventId: string) {
|
||||
return path.join(UPLOADS_ROOT, eventId);
|
||||
}
|
||||
|
||||
/** Public URL path for a stored photo, for use directly in <img src>. */
|
||||
export function photoUrl(eventId: string, filename: string): string {
|
||||
return `/uploads/${eventId}/${filename}`;
|
||||
}
|
||||
|
||||
/** Writes an uploaded file to disk under a per-event directory and returns
|
||||
* the generated filename (not the full path - callers store just this). */
|
||||
export async function savePhotoFile(eventId: string, file: File): Promise<string> {
|
||||
const ext = ALLOWED_PHOTO_TYPES[file.type];
|
||||
if (!ext) {
|
||||
throw new Error(`Unsupported file type: ${file.type}`);
|
||||
}
|
||||
|
||||
const dir = eventDir(eventId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const filename = `${randomBytes(12).toString('hex')}.${ext}`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(path.join(dir, filename), buffer);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
export async function deletePhotoFile(eventId: string, filename: string): Promise<void> {
|
||||
try {
|
||||
await unlink(path.join(eventDir(eventId), filename));
|
||||
} catch (err) {
|
||||
// Already gone is fine; anything else is worth knowing about but
|
||||
// shouldn't block deleting the DB record.
|
||||
console.error(`Failed to delete photo file ${filename} for event ${eventId}:`, err);
|
||||
}
|
||||
}
|
||||
43
src/lib/photoWindow.test.ts
Normal file
43
src/lib/photoWindow.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||
|
||||
const base = {
|
||||
eventDate: new Date('2027-06-01T18:00:00'),
|
||||
photosEnabled: true,
|
||||
photosClosed: false,
|
||||
photoCloseDate: null as Date | null,
|
||||
};
|
||||
|
||||
describe('isPhotoUploadOpen', () => {
|
||||
it('is closed before the event starts', () => {
|
||||
const now = new Date('2027-06-01T10:00:00');
|
||||
expect(isPhotoUploadOpen(base, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('is open once the event has started with no close date', () => {
|
||||
const now = new Date('2027-06-01T19:00:00');
|
||||
expect(isPhotoUploadOpen(base, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('is closed if the feature is disabled', () => {
|
||||
const now = new Date('2027-06-01T19:00:00');
|
||||
expect(isPhotoUploadOpen({ ...base, photosEnabled: false }, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('is closed if force-closed, even mid-window', () => {
|
||||
const now = new Date('2027-06-01T19:00:00');
|
||||
expect(isPhotoUploadOpen({ ...base, photosClosed: true }, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('is open before the close date', () => {
|
||||
const now = new Date('2027-06-02T10:00:00');
|
||||
const closeDate = new Date('2027-06-03T00:00:00');
|
||||
expect(isPhotoUploadOpen({ ...base, photoCloseDate: closeDate }, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('is closed after the close date', () => {
|
||||
const now = new Date('2027-06-04T10:00:00');
|
||||
const closeDate = new Date('2027-06-03T00:00:00');
|
||||
expect(isPhotoUploadOpen({ ...base, photoCloseDate: closeDate }, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
19
src/lib/photoWindow.ts
Normal file
19
src/lib/photoWindow.ts
Normal file
@ -0,0 +1,19 @@
|
||||
type PhotoWindowEvent = {
|
||||
eventDate: Date;
|
||||
photosEnabled: boolean;
|
||||
photosClosed: boolean;
|
||||
photoCloseDate: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether guests can currently upload photos: the feature must be enabled,
|
||||
* not force-closed by the host, the event must have actually started, and
|
||||
* (if set) any auto-close date must not have passed yet.
|
||||
*/
|
||||
export function isPhotoUploadOpen(event: PhotoWindowEvent, now: Date = new Date()): boolean {
|
||||
if (!event.photosEnabled) return false;
|
||||
if (event.photosClosed) return false;
|
||||
if (now < event.eventDate) return false;
|
||||
if (event.photoCloseDate && now > event.photoCloseDate) return false;
|
||||
return true;
|
||||
}
|
||||
9
src/lib/prisma.ts
Normal file
9
src/lib/prisma.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
52
src/lib/push.ts
Normal file
52
src/lib/push.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import 'server-only';
|
||||
import webpush from 'web-push';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
let configured = false;
|
||||
|
||||
function ensureConfigured() {
|
||||
if (configured) return true;
|
||||
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
||||
if (!publicKey || !privateKey) return false;
|
||||
|
||||
webpush.setVapidDetails(
|
||||
`mailto:${process.env.VAPID_CONTACT_EMAIL || 'admin@example.com'}`,
|
||||
publicKey,
|
||||
privateKey
|
||||
);
|
||||
configured = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pushes a notification to every browser subscription a user has registered.
|
||||
* Silently does nothing if VAPID keys aren't configured, or the user has no
|
||||
* subscriptions - this always runs alongside email, never in place of it. */
|
||||
export async function sendPushToUser(
|
||||
userId: string,
|
||||
payload: { title: string; body: string; url?: string }
|
||||
) {
|
||||
if (!ensureConfigured()) return;
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({ where: { userId } });
|
||||
if (subscriptions.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
subscriptions.map(async (sub) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
} catch (err) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode;
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
// Subscription expired or was revoked by the browser - clean it up.
|
||||
await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => {});
|
||||
} else {
|
||||
console.error(`Failed to send push notification to subscription ${sub.id}:`, err);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
59
src/lib/rateLimit.test.ts
Normal file
59
src/lib/rateLimit.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { checkRateLimit } from '@/lib/rateLimit';
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('allows requests under the limit', () => {
|
||||
const key = `test:${Math.random()}`;
|
||||
expect(checkRateLimit(key, 3, 1000)).toBe(true);
|
||||
expect(checkRateLimit(key, 3, 1000)).toBe(true);
|
||||
expect(checkRateLimit(key, 3, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects once the limit is exceeded', () => {
|
||||
const key = `test:${Math.random()}`;
|
||||
checkRateLimit(key, 2, 1000);
|
||||
checkRateLimit(key, 2, 1000);
|
||||
expect(checkRateLimit(key, 2, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it('resets after the window elapses', () => {
|
||||
const key = `test:${Math.random()}`;
|
||||
checkRateLimit(key, 1, 1000);
|
||||
expect(checkRateLimit(key, 1, 1000)).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
expect(checkRateLimit(key, 1, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks separate keys independently', () => {
|
||||
const keyA = `test:a:${Math.random()}`;
|
||||
const keyB = `test:b:${Math.random()}`;
|
||||
checkRateLimit(keyA, 1, 1000);
|
||||
expect(checkRateLimit(keyA, 1, 1000)).toBe(false);
|
||||
expect(checkRateLimit(keyB, 1, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an oversized batch without partially consuming the bucket', () => {
|
||||
const key = `test:${Math.random()}`;
|
||||
expect(checkRateLimit(key, 10, 1000, 20)).toBe(false);
|
||||
// Since the oversized request was rejected, a normal-cost request should
|
||||
// still succeed against a fresh/untouched bucket.
|
||||
expect(checkRateLimit(key, 10, 1000, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('weights hits by cost', () => {
|
||||
const key = `test:${Math.random()}`;
|
||||
expect(checkRateLimit(key, 10, 1000, 6)).toBe(true);
|
||||
expect(checkRateLimit(key, 10, 1000, 5)).toBe(false);
|
||||
expect(checkRateLimit(key, 10, 1000, 4)).toBe(true);
|
||||
});
|
||||
});
|
||||
56
src/lib/rateLimit.ts
Normal file
56
src/lib/rateLimit.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import 'server-only';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
|
||||
// In-memory fixed-window limiter. This is process-local: it resets on
|
||||
// restart and isn't shared across replicas if this app is ever scaled
|
||||
// horizontally. That's an acceptable tradeoff for a self-hosted, single
|
||||
// -instance app - swap for a shared store (e.g. Redis) if you scale out.
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanup(now: number) {
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||
lastCleanup = now;
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.resetAt < now) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the action identified by `key` is allowed under `limit`
|
||||
* hits per `windowMs`, and records this attempt (weighted by `cost`, for
|
||||
* actions like sending a batch of N emails at once). Returns false if the
|
||||
* caller should be rejected as rate-limited; the bucket is left unchanged
|
||||
* in that case so a single oversized request doesn't get partially counted.
|
||||
*/
|
||||
export function checkRateLimit(key: string, limit: number, windowMs: number, cost = 1): boolean {
|
||||
const now = Date.now();
|
||||
cleanup(now);
|
||||
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket || bucket.resetAt < now) {
|
||||
if (cost > limit) return false;
|
||||
buckets.set(key, { count: cost, resetAt: now + windowMs });
|
||||
return true;
|
||||
}
|
||||
if (bucket.count + cost > limit) return false;
|
||||
bucket.count += cost;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort client IP from X-Forwarded-For, set by a reverse proxy in
|
||||
* front of the app. Without one (e.g. hitting the container directly),
|
||||
* this collapses to a single shared bucket for all clients - callers should
|
||||
* always pair this with a second, more specific key (event id, email, etc).
|
||||
*/
|
||||
export async function getClientIp(): Promise<string> {
|
||||
const h = await headers();
|
||||
const forwarded = h.get('x-forwarded-for');
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return 'unknown';
|
||||
}
|
||||
79
src/lib/session.ts
Normal file
79
src/lib/session.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import 'server-only';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
const SESSION_COOKIE = 'einvite_session';
|
||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
// Only mark the cookie Secure when explicitly opted in. NODE_ENV=production
|
||||
// is set in the Docker image regardless of whether TLS is terminated in
|
||||
// front of it, and this app is served over plain HTTP by default (e.g. the
|
||||
// bundled docker-compose stack) - a Secure cookie would silently never be
|
||||
// sent back by the browser in that case, breaking every session right after
|
||||
// login. Set COOKIE_SECURE=true once you have HTTPS in front of the app.
|
||||
const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true';
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export async function createSession(userId: string) {
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, session.id, {
|
||||
httpOnly: true,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: session.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function destroySession() {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (sessionId) {
|
||||
await prisma.session.deleteMany({ where: { id: sessionId } });
|
||||
}
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
if (session) {
|
||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => {});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return session.user;
|
||||
}
|
||||
|
||||
export async function requireUser() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
13
src/lib/slug.ts
Normal file
13
src/lib/slug.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
// Unambiguous alphabet (no 0/O/1/l/I) for share links that get typed or read aloud.
|
||||
const nanoid = customAlphabet('23456789abcdefghjkmnpqrstuvwxyz', 12);
|
||||
|
||||
export function generateSlug() {
|
||||
return nanoid();
|
||||
}
|
||||
|
||||
export function appUrl(path: string) {
|
||||
const base = process.env.APP_URL ?? 'http://localhost:3000';
|
||||
return `${base.replace(/\/$/, '')}${path}`;
|
||||
}
|
||||
62
src/lib/sms.test.ts
Normal file
62
src/lib/sms.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import twilio from 'twilio';
|
||||
import { verifyTwilioSignature, isTwilioConfigured } from '@/lib/sms';
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
describe('verifyTwilioSignature', () => {
|
||||
beforeEach(() => {
|
||||
process.env.TWILIO_AUTH_TOKEN = 'test_auth_token';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
const url = 'https://example.com/api/sms/webhook';
|
||||
const params = { From: '+15551234567', Body: 'yes' };
|
||||
|
||||
it('accepts a genuinely valid Twilio signature', () => {
|
||||
const signature = twilio.getExpectedTwilioSignature('test_auth_token', url, params);
|
||||
expect(verifyTwilioSignature(url, params, signature)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered signature', () => {
|
||||
expect(verifyTwilioSignature(url, params, 'not-a-real-signature')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a valid signature computed for different params (replay onto a different body)', () => {
|
||||
const signature = twilio.getExpectedTwilioSignature('test_auth_token', url, params);
|
||||
expect(verifyTwilioSignature(url, { From: '+15551234567', Body: 'no' }, signature)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when no signature is provided', () => {
|
||||
expect(verifyTwilioSignature(url, params, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the auth token is not configured', () => {
|
||||
delete process.env.TWILIO_AUTH_TOKEN;
|
||||
const signature = twilio.getExpectedTwilioSignature('test_auth_token', url, params);
|
||||
expect(verifyTwilioSignature(url, params, signature)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTwilioConfigured', () => {
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it('is false when env vars are missing', () => {
|
||||
delete process.env.TWILIO_ACCOUNT_SID;
|
||||
delete process.env.TWILIO_AUTH_TOKEN;
|
||||
delete process.env.TWILIO_PHONE_NUMBER;
|
||||
expect(isTwilioConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when all three env vars are set', () => {
|
||||
process.env.TWILIO_ACCOUNT_SID = 'ACxxx';
|
||||
process.env.TWILIO_AUTH_TOKEN = 'token';
|
||||
process.env.TWILIO_PHONE_NUMBER = '+15551234567';
|
||||
expect(isTwilioConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
42
src/lib/sms.ts
Normal file
42
src/lib/sms.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import 'server-only';
|
||||
import twilio from 'twilio';
|
||||
|
||||
export function isTwilioConfigured(): boolean {
|
||||
return Boolean(process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN && process.env.TWILIO_PHONE_NUMBER);
|
||||
}
|
||||
|
||||
let client: ReturnType<typeof twilio> | null = null;
|
||||
|
||||
function getClient() {
|
||||
if (!client) {
|
||||
client = twilio(process.env.TWILIO_ACCOUNT_SID!, process.env.TWILIO_AUTH_TOKEN!);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Returns whether the send succeeded rather than throwing, same resilience
|
||||
* contract as sendMail - a down/misconfigured Twilio account shouldn't turn
|
||||
* "add this guest to the list" into a 500. */
|
||||
export async function sendSms(to: string, body: string): Promise<boolean> {
|
||||
if (!isTwilioConfigured()) return false;
|
||||
try {
|
||||
await getClient().messages.create({ to, from: process.env.TWILIO_PHONE_NUMBER!, body });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to send SMS to ${to}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verifies an inbound webhook request actually came from Twilio, using the
|
||||
* same HMAC scheme Twilio's own helper library implements - the request's
|
||||
* full URL plus its POST params, signed with the account's auth token. */
|
||||
export function verifyTwilioSignature(
|
||||
url: string,
|
||||
params: Record<string, string>,
|
||||
signature: string | null
|
||||
): boolean {
|
||||
const authToken = process.env.TWILIO_AUTH_TOKEN;
|
||||
if (!signature || !authToken) return false;
|
||||
return twilio.validateRequest(authToken, signature, url, params);
|
||||
}
|
||||
43
src/lib/smsReply.test.ts
Normal file
43
src/lib/smsReply.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSmsRsvpReply } from '@/lib/smsReply';
|
||||
|
||||
describe('parseSmsRsvpReply', () => {
|
||||
it('parses a plain yes', () => {
|
||||
expect(parseSmsRsvpReply('YES')).toEqual({ status: 'YES', numGuests: 1 });
|
||||
});
|
||||
|
||||
it('parses a plain no', () => {
|
||||
expect(parseSmsRsvpReply('no')).toEqual({ status: 'NO', numGuests: 1 });
|
||||
});
|
||||
|
||||
it('parses a plain maybe', () => {
|
||||
expect(parseSmsRsvpReply('Maybe')).toEqual({ status: 'MAYBE', numGuests: 1 });
|
||||
});
|
||||
|
||||
it('parses common casual variants', () => {
|
||||
expect(parseSmsRsvpReply('yeah')).toEqual({ status: 'YES', numGuests: 1 });
|
||||
expect(parseSmsRsvpReply('nope')).toEqual({ status: 'NO', numGuests: 1 });
|
||||
});
|
||||
|
||||
it('extracts a guest count that follows the status word', () => {
|
||||
expect(parseSmsRsvpReply('YES 3')).toEqual({ status: 'YES', numGuests: 3 });
|
||||
expect(parseSmsRsvpReply('Yes, 2 guests please')).toEqual({ status: 'YES', numGuests: 2 });
|
||||
});
|
||||
|
||||
it('handles a full sentence reply led by the status word', () => {
|
||||
expect(parseSmsRsvpReply("Yes we'll be there!")).toEqual({ status: 'YES', numGuests: 1 });
|
||||
expect(parseSmsRsvpReply('No, something came up')).toEqual({ status: 'NO', numGuests: 1 });
|
||||
});
|
||||
|
||||
it('clamps an absurd guest count to 50', () => {
|
||||
expect(parseSmsRsvpReply('yes 500')).toEqual({ status: 'YES', numGuests: 50 });
|
||||
});
|
||||
|
||||
it('returns null for an unrecognized reply', () => {
|
||||
expect(parseSmsRsvpReply('what is this event about?')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for blank input', () => {
|
||||
expect(parseSmsRsvpReply(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
33
src/lib/smsReply.ts
Normal file
33
src/lib/smsReply.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { RsvpStatus } from '@prisma/client';
|
||||
|
||||
export type ParsedSmsRsvpReply = { status: RsvpStatus; numGuests: number };
|
||||
|
||||
const YES_WORDS = new Set(['yes', 'y', 'yep', 'yeah', 'yup', 'ya']);
|
||||
const NO_WORDS = new Set(['no', 'n', 'nope', 'nah']);
|
||||
const MAYBE_WORDS = new Set(['maybe', 'perhaps', 'unsure', 'tentative', 'possibly']);
|
||||
|
||||
/**
|
||||
* Parses a guest's free-form SMS reply into an RSVP status plus an optional
|
||||
* guest count (e.g. "YES 3", "no", "Maybe, 2 people"). Only the first word is
|
||||
* used to determine status - guests reply in all kinds of phrasing, but the
|
||||
* status word almost always leads. Returns null when no recognized status
|
||||
* word is found, so the caller can ask the guest to try again.
|
||||
*/
|
||||
export function parseSmsRsvpReply(rawBody: string): ParsedSmsRsvpReply | null {
|
||||
const body = rawBody.trim();
|
||||
if (!body) return null;
|
||||
|
||||
const firstWord = body.toLowerCase().match(/[a-z']+/)?.[0];
|
||||
if (!firstWord) return null;
|
||||
|
||||
let status: RsvpStatus;
|
||||
if (YES_WORDS.has(firstWord)) status = 'YES';
|
||||
else if (NO_WORDS.has(firstWord)) status = 'NO';
|
||||
else if (MAYBE_WORDS.has(firstWord)) status = 'MAYBE';
|
||||
else return null;
|
||||
|
||||
const countMatch = body.match(/\d+/);
|
||||
const numGuests = countMatch ? Math.min(Math.max(parseInt(countMatch[0], 10), 1), 50) : 1;
|
||||
|
||||
return { status, numGuests };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user