commit 2c78112a4f9d7b060a10c2f98c2f62e90244a24c Author: chris Date: Sat Jul 11 10:11:12 2026 -0400 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. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9658ccd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +*.log +.env +.env.local diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a0c393a --- /dev/null +++ b/.env.example @@ -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 " +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="" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92e6fef --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +.next +out +build +.env +.env.local +*.log +.DS_Store +*.swp +*.swo +/public/uploads/* +!/public/uploads/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c4cf7e6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..da0205e --- /dev/null +++ b/README.md @@ -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 " +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e29257d --- /dev/null +++ b/docker-compose.yml @@ -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 } + 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: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..7d165eb --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +npx prisma migrate deploy + +echo "Starting app..." +exec "$@" diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +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. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..641dd56 --- /dev/null +++ b/next.config.js @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cb14c36 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3843 @@ +{ + "name": "einvite", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "einvite", + "version": "1.0.0", + "hasInstallScript": true, + "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" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", + "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", + "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", + "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", + "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", + "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", + "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", + "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", + "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", + "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-80vKwiIsVSyFA1rRovH59jNPLBOuc6dRZIHEu40gXTkBkZnQv8vog1xSGEb9j5q/tdMAs5ivvDR2pLTU0hGHXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.8.tgz", + "integrity": "sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/next": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", + "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.10", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.10", + "@next/swc-darwin-x64": "16.2.10", + "@next/swc-linux-arm64-gnu": "16.2.10", + "@next/swc-linux-arm64-musl": "16.2.10", + "@next/swc-linux-x64-gnu": "16.2.10", + "@next/swc-linux-x64-musl": "16.2.10", + "@next/swc-win32-arm64-msvc": "16.2.10", + "@next/swc-win32-x64-msvc": "16.2.10", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/twilio": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-6.0.2.tgz", + "integrity": "sha512-RN3TZxUtxLz2HBZVt62+LdZxQbrMVgYKtuzLgwmO7nqKvR+gQS5mCackD9hf4Y7MmoK/bX7tCm7kaJC8kC8zFA==", + "license": "MIT", + "dependencies": { + "axios": "^1.13.5", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.3", + "qs": "^6.14.1", + "scmp": "^2.1.0", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/twilio/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/twilio/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5942556 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prisma/migrations/20260710203158_init/migration.sql b/prisma/migrations/20260710203158_init/migration.sql new file mode 100644 index 0000000..13c6492 --- /dev/null +++ b/prisma/migrations/20260710203158_init/migration.sql @@ -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; diff --git a/prisma/migrations/20260710203200_rsvp_upsert_dedup/migration.sql b/prisma/migrations/20260710203200_rsvp_upsert_dedup/migration.sql new file mode 100644 index 0000000..6152e08 --- /dev/null +++ b/prisma/migrations/20260710203200_rsvp_upsert_dedup/migration.sql @@ -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"); + diff --git a/prisma/migrations/20260710210000_collaborators_and_event_details/migration.sql b/prisma/migrations/20260710210000_collaborators_and_event_details/migration.sql new file mode 100644 index 0000000..90b3aaa --- /dev/null +++ b/prisma/migrations/20260710210000_collaborators_and_event_details/migration.sql @@ -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; + diff --git a/prisma/migrations/20260711000000_photo_dropbox/migration.sql b/prisma/migrations/20260711000000_photo_dropbox/migration.sql new file mode 100644 index 0000000..3211579 --- /dev/null +++ b/prisma/migrations/20260711000000_photo_dropbox/migration.sql @@ -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; + diff --git a/prisma/migrations/20260711010000_email_verification/migration.sql b/prisma/migrations/20260711010000_email_verification/migration.sql new file mode 100644 index 0000000..d7a6aca --- /dev/null +++ b/prisma/migrations/20260711010000_email_verification/migration.sql @@ -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"); + diff --git a/prisma/migrations/20260711020000_occasion_contact_info/migration.sql b/prisma/migrations/20260711020000_occasion_contact_info/migration.sql new file mode 100644 index 0000000..85c6519 --- /dev/null +++ b/prisma/migrations/20260711020000_occasion_contact_info/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "contactInfo" TEXT NOT NULL DEFAULT '', +ADD COLUMN "occasion" TEXT NOT NULL DEFAULT ''; + diff --git a/prisma/migrations/20260711030000_notification_preferences/migration.sql b/prisma/migrations/20260711030000_notification_preferences/migration.sql new file mode 100644 index 0000000..fbd6196 --- /dev/null +++ b/prisma/migrations/20260711030000_notification_preferences/migration.sql @@ -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; + diff --git a/prisma/migrations/20260711040000_push_subscriptions/migration.sql b/prisma/migrations/20260711040000_push_subscriptions/migration.sql new file mode 100644 index 0000000..bcc0cf3 --- /dev/null +++ b/prisma/migrations/20260711040000_push_subscriptions/migration.sql @@ -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; + diff --git a/prisma/migrations/20260711050000_audit_fixes_schema/migration.sql b/prisma/migrations/20260711050000_audit_fixes_schema/migration.sql new file mode 100644 index 0000000..df5fccb --- /dev/null +++ b/prisma/migrations/20260711050000_audit_fixes_schema/migration.sql @@ -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"); diff --git a/prisma/migrations/20260711060000_restricted_rsvp/migration.sql b/prisma/migrations/20260711060000_restricted_rsvp/migration.sql new file mode 100644 index 0000000..b8839f6 --- /dev/null +++ b/prisma/migrations/20260711060000_restricted_rsvp/migration.sql @@ -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; diff --git a/prisma/migrations/20260711070000_sms_support/migration.sql b/prisma/migrations/20260711070000_sms_support/migration.sql new file mode 100644 index 0000000..9c6a5d6 --- /dev/null +++ b/prisma/migrations/20260711070000_sms_support/migration.sql @@ -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); + diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..b79bfce --- /dev/null +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..4b350cd --- /dev/null +++ b/public/sw.js @@ -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); + }) + ); +}); diff --git a/public/uploads/.gitkeep b/public/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..403816b --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -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 ( +
+

Reset your password

+

+ Enter your email and we'll send you a link to reset your password. +

+ +
+
+ + +
+ + {state.error &&

{state.error}

} + {state.success &&

{state.success}

} + + Send reset link +
+ +

+ + Back to log in + +

+
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ad8e0b0 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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 ( + + + + ); +} + +function LoginForm() { + const searchParams = useSearchParams(); + const next = searchParams.get('next') ?? ''; + const [state, formAction] = useActionState(loginAction, initialState); + + return ( +
+

Log in

+ +
+ +
+ + +
+
+ + +
+ + {state.error &&

{state.error}

} + + Log in +
+ +
+ + Create an account + + + Forgot password? + +
+
+ ); +} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..bb91321 --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -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 ( + + + + ); +} + +function ResetPasswordForm() { + const searchParams = useSearchParams(); + const token = searchParams.get('token') ?? ''; + const [state, formAction] = useActionState(resetPasswordAction, initialState); + + return ( +
+

Set a new password

+ + {!token ? ( +

+ This reset link is missing its token. Please use the link from your email. +

+ ) : ( +
+ +
+ + +

At least 8 characters, with a letter and a number.

+
+
+ + +
+ + {state.error &&

{state.error}

} + {state.success &&

{state.success}

} + + Update password +
+ )} + +

+ + Back to log in + +

+
+ ); +} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..b2ad963 --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -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 ( + + + + ); +} + +function SignupForm() { + const searchParams = useSearchParams(); + const next = searchParams.get('next') ?? ''; + const [state, formAction] = useActionState(signupAction, initialState); + + return ( +
+

Create your account

+

Start hosting themed invitations in minutes.

+ +
+ +
+ + +
+
+ + +
+
+ + +

At least 8 characters, with a letter and a number.

+
+
+ + +
+ + {state.error &&

{state.error}

} + + Sign up +
+ +

+ Already have an account?{' '} + + Log in + +

+
+ ); +} diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts new file mode 100644 index 0000000..6ee7d4f --- /dev/null +++ b/src/app/actions/auth.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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.' }; +} diff --git a/src/app/actions/collaborators.ts b/src/app/actions/collaborators.ts new file mode 100644 index 0000000..201a49c --- /dev/null +++ b/src/app/actions/collaborators.ts @@ -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 { + 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`); +} diff --git a/src/app/actions/comments.ts b/src/app/actions/comments.ts new file mode 100644 index 0000000..ef0609c --- /dev/null +++ b/src/app/actions/comments.ts @@ -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 { + // 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}`); +} diff --git a/src/app/actions/events.ts b/src/app/actions/events.ts new file mode 100644 index 0000000..de2b4c4 --- /dev/null +++ b/src/app/actions/events.ts @@ -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 { + 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 { + 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`); +} diff --git a/src/app/actions/invites.ts b/src/app/actions/invites.ts new file mode 100644 index 0000000..5959f0d --- /dev/null +++ b/src/app/actions/invites.ts @@ -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 { + 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`); +} diff --git a/src/app/actions/photos.ts b/src/app/actions/photos.ts new file mode 100644 index 0000000..845b257 --- /dev/null +++ b/src/app/actions/photos.ts @@ -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 { + 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}`); +} diff --git a/src/app/actions/push.ts b/src/app/actions/push.ts new file mode 100644 index 0000000..eb1b5d5 --- /dev/null +++ b/src/app/actions/push.ts @@ -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 } }); +} diff --git a/src/app/actions/rsvp.ts b/src/app/actions/rsvp.ts new file mode 100644 index 0000000..c9f5785 --- /dev/null +++ b/src/app/actions/rsvp.ts @@ -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 { + // 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 }; +} diff --git a/src/app/actions/settings.ts b/src/app/actions/settings.ts new file mode 100644 index 0000000..cad1a0b --- /dev/null +++ b/src/app/actions/settings.ts @@ -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 { + 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.' }; +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..de40ac4 --- /dev/null +++ b/src/app/api/health/route.ts @@ -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 }); + } +} diff --git a/src/app/api/sms/webhook/route.ts b/src/app/api/sms/webhook/route.ts new file mode 100644 index 0000000..7d7c73f --- /dev/null +++ b/src/app/api/sms/webhook/route.ts @@ -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, '>'); + return new NextResponse( + `${escaped}`, + { status: 200, headers: { 'Content-Type': 'text/xml' } } + ); +} + +const STATUS_LABEL: Record = { + 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.`); +} diff --git a/src/app/collaborate/[token]/page.tsx b/src/app/collaborate/[token]/page.tsx new file mode 100644 index 0000000..659cd25 --- /dev/null +++ b/src/app/collaborate/[token]/page.tsx @@ -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 ( +
+

Co-host invite

+

+ You've been invited to help host {collaborator.event.title} — edit + details, view RSVPs, and moderate comments together. +

+ + {isExpired ? ( +

+ This invite link has expired. Ask the host to send you a new one. +

+ ) : !user ? ( +
+ + Create an account to accept + + + Log in to accept + +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/dashboard/events/[id]/edit/page.tsx b/src/app/dashboard/events/[id]/edit/page.tsx new file mode 100644 index 0000000..c89844d --- /dev/null +++ b/src/app/dashboard/events/[id]/edit/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/dashboard/events/[id]/guests/page.tsx b/src/app/dashboard/events/[id]/guests/page.tsx new file mode 100644 index 0000000..fc3a818 --- /dev/null +++ b/src/app/dashboard/events/[id]/guests/page.tsx @@ -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 ( + +
+
+
+ + {theme.emoji} + +
+

{event.title}

+

{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}

+
+
+
+ + Edit + + {isPrimaryHost && ( +
+ +
+ )} +
+
+ +
+

Share link

+

+ {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. +

+
+ +
+
+ +
+

Co-hosts

+

+ Co-hosts can edit the invite, view RSVPs, and moderate comments, but can't delete the + event or manage other co-hosts. +

+
    +
  • + + {event.host.name} ({event.host.email}) + + Primary host +
  • + {event.collaborators.map((collaborator) => ( +
  • + + {collaborator.user?.name ?? collaborator.invitedEmail}{' '} + ({collaborator.invitedEmail}) + +
    + + {collaborator.status === 'ACCEPTED' ? 'Co-host' : 'Invite pending'} + + {isPrimaryHost && ( +
    + +
    + )} +
    +
  • + ))} +
+ {isPrimaryHost && ( +
+ +
+ )} +
+ +
+

Guest list

+

+ 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.'} +

+
+ +
+ {event.restrictRsvpToInvited && event.guestInvites.length === 0 && ( +

+ 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. +

+ )} + {event.guestInvites.length > 0 && ( +
    + {event.guestInvites.map((invite) => ( +
  • + {invite.email ?? invite.phone} +
    + + {invite.email + ? invite.emailSent + ? 'Emailed' + : 'Added, not emailed' + : invite.smsSent + ? 'Texted' + : 'Added, not texted'} + +
    + +
    +
    +
  • + ))} +
+ )} +
+ +
+

+ RSVPs + {event.restrictRsvpToInvited && ( + + Restricted to guest list + + )} +

+
+ {tally.YES} attending + {tally.MAYBE} maybe + {tally.NO} declined +
+ {event.rsvps.length === 0 ? ( +

No responses yet.

+ ) : ( + + + + + + + + + + + {event.rsvps.map((rsvp) => ( + + + + + + + ))} + +
NameStatusGuestsNote
+
{rsvp.guestName}
+
{rsvp.guestEmail}
+
{rsvp.status}{rsvp.numGuests}{rsvp.note}
+ )} +
+ +
+

Comments

+ {event.comments.length === 0 ? ( +

No comments yet.

+ ) : ( +
    + {event.comments.map((comment) => ( +
  • +
    +
    +

    + {comment.authorName} {comment.hidden && (hidden)} +

    +

    {comment.body}

    +
    +
    +
    + +
    +
    + +
    +
    +
    +
  • + ))} +
+ )} +
+ +
+
+

Photos

+ {event.photosEnabled && ( +
+ +
+ )} +
+

+ {!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.'} +

+ {event.photos.length === 0 ? ( +

No photos yet.

+ ) : ( +
+ {event.photos.map((photo) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {photo.uploaderName +
+
+ +
+
+ +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/dashboard/events/new/page.tsx b/src/app/dashboard/events/new/page.tsx new file mode 100644 index 0000000..2e37dc6 --- /dev/null +++ b/src/app/dashboard/events/new/page.tsx @@ -0,0 +1,6 @@ +import { EventEditor } from '@/components/EventEditor'; +import { createEventAction } from '@/app/actions/events'; + +export default function NewEventPage() { + return ; +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..9953f1b --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -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 ( +
+
+
+ + E-Invite + +
+ + Settings + + {user.name} +
+ +
+
+
+
+ {!user.emailVerified && } + {/* 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. */} +
{children}
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..0fc0712 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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 ( + +
+

Your events

+ + New event + +
+ + {events.length === 0 ? ( +

+ You haven't created any events yet.{' '} + + Create your first invite + + . +

+ ) : ( +
    + {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 ( +
  • + +
    + + {theme.emoji} + +
    +

    + {event.title} + {event.hostId !== user.id && ( + + Co-hosting + + )} +

    +

    + {event.eventDate.toLocaleDateString('en-US', { + dateStyle: 'medium', + timeZone: event.timezone, + })} +

    +
    +
    +
    +

    {yesCount} attending

    +

    {event._count.comments} comments

    +
    + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..117f3e7 --- /dev/null +++ b/src/app/dashboard/settings/page.tsx @@ -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 ( + +

Settings

+ +
+
+

Email notifications

+

+ Choose which emails you get as a host. This applies to events you host directly - not + ones you're co-hosting. +

+
+ +
+
+ +
+

Browser push notifications

+
+ +
+
+
+
+ ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..e724af0 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,26 @@ +'use client'; + +export default function GlobalError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+

Something went wrong

+

+ Sorry about that - an unexpected error occurred. You can try again, or head back home. +

+
+ + + Go home + +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..16a1c75 --- /dev/null +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/invite/[slug]/page.tsx b/src/app/invite/[slug]/page.tsx new file mode 100644 index 0000000..025be2d --- /dev/null +++ b/src/app/invite/[slug]/page.tsx @@ -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 ( + +
+
+ {theme.emoji} +
+

You're invited by {event.host.name}

+

{event.title}

+ {event.occasion &&

{event.occasion}

} +

{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}

+ {event.location &&

{event.location}

} + {(event.foodProvided || event.drinksProvided) && ( +
+ {event.foodProvided && ( + + 🍽️ Food will be provided + + )} + {event.drinksProvided && ( + + 🍹 Drinks will be provided + + )} +
+ )} +
+ + + + {event.description && ( +
+ {event.description} +
+ )} + +
+

RSVP

+ {event.restrictRsvpToInvited && ( +

This event is invite-only.

+ )} + {rsvpClosed ? ( +

RSVPs are closed for this event.

+ ) : ( +
+ +
+ )} +
+ + {event.commentsEnabled && ( + <> + +
+

Well wishes

+
+ +
+ + {event.comments.length > 0 && ( +
    + {event.comments.map((comment) => ( +
  • +

    {comment.authorName}

    +

    {comment.body}

    +
  • + ))} +
+ )} +
+ + )} + + {showPhotos && ( + <> + +
+

Photos

+ {photosOpen ? ( +
+ +
+ ) : ( +

Photo uploads are closed for this event.

+ )} + ({ + id: p.id, + url: photoUrl(event.id, p.filename), + uploaderName: p.uploaderName, + }))} + /> +
+ + )} + + {event.contactInfo && ( +

{event.contactInfo}

+ )} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..2d41623 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..b21ed4e --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link'; + +export default function NotFound() { + return ( +
+

Page not found

+

+ The page or invite you're looking for doesn't exist, or the link may have been regenerated. +

+ + Go home + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..e040a6d --- /dev/null +++ b/src/app/page.tsx @@ -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 ( +
+
+

E-Invite

+

+ Create beautiful themed online invitations, collect RSVPs, and share a single link + with everyone you're inviting. +

+
+ +
+ + Get started + + + Log in + +
+ +
+ {THEMES.map((theme) => ( +
+ {theme.emoji} + {theme.name} +
+ ))} +
+
+ ); +} diff --git a/src/app/verify-email/[token]/page.tsx b/src/app/verify-email/[token]/page.tsx new file mode 100644 index 0000000..9361f58 --- /dev/null +++ b/src/app/verify-email/[token]/page.tsx @@ -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 ( +
+

Email verification

+ {result.success ? ( +

{result.success}

+ ) : ( +

{result.error}

+ )} + + Go to dashboard + +
+ ); +} diff --git a/src/components/CommentForm.tsx b/src/components/CommentForm.tsx new file mode 100644 index 0000000..d45e2bf --- /dev/null +++ b/src/components/CommentForm.tsx @@ -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 ( +
+ + +
+ + +
+