commit 41937ffc1567d5a9e70ce64c6cf3ca9f2a6088b0 Author: Andymick Date: Wed Jul 15 18:09:59 2026 +0100 Initial commit: Craft2Prints with Stages 1-3 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..04ffa28 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9afb4de --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Read", + "Edit", + "Write", + "Glob", + "Grep", + "Bash(git *)", + "Bash(npm *)", + "Bash(npx *)" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bd8ff37 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Skill(update-config)", + "Skill(update-config:*)", + "Bash(npx prisma *)", + "Bash(xargs grep -l \"effectivePrice\")", + "Bash(npx tsc *)", + "Bash(powershell -Command \"Get-ChildItem D:\\\\Craft2Prints -Filter '.env*' -Force | Select-Object Name\")", + "Bash(dir /a D:\\\\Craft2Prints\\\\.env*)", + "Bash(git add *)", + "Bash(git restore *)", + "Bash(git reset *)", + "Bash(git checkout *)", + "Bash(git commit -m ' *)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7e74068 --- /dev/null +++ b/.env.example @@ -0,0 +1,51 @@ +# Database (SQLite by default — works out of the box on any server). +# For Postgres later, change provider in prisma/schema.prisma and set this to your Postgres URL. +DATABASE_URL="file:./dev.db" + +# Stripe — get these from https://dashboard.stripe.com/apikeys +STRIPE_SECRET_KEY="sk_live_or_test_..." +STRIPE_WEBHOOK_SECRET="whsec_..." + +# Public base URL of your deployed site (used for Stripe redirect URLs) +NEXT_PUBLIC_SITE_URL="https://yourdomain.com" + +# Your admin login credentials, checked on the /admin/login page. +# CHANGE THESE before deploying — the defaults are public in this codebase. +ADMIN_USER="admin" +ADMIN_PASSWORD="changeme" + +# Signs your admin login session (separate from the customer login below). +# Any long random string works — e.g. run `openssl rand -base64 32` in a terminal, +# or just mash the keyboard for 40+ characters. CHANGE THIS before deploying. +ADMIN_SESSION_SECRET="replace-with-a-long-random-string" + +# Signs customer account login sessions (separate from the admin login above). +# Any long random string works — e.g. run `openssl rand -base64 32` in a terminal, +# or just mash the keyboard for 40+ characters. CHANGE THIS before deploying. +SESSION_SECRET="replace-with-a-long-random-string" + +# SMTP — used to email customers their design-approval link directly. +# Leave SMTP_HOST blank to skip emailing; you'll just get a link to copy/send yourself. +# Example values for Gmail: host smtp.gmail.com, port 587, user your address, +# password an "app password" (not your normal Gmail password). +SMTP_HOST="" +SMTP_PORT="587" +SMTP_USER="" +SMTP_PASSWORD="" +MAIL_FROM="Craft2Prints " + +# Where "new photo request" alerts get sent when a customer submits a photo at +# /custom-request. Leave blank to default to SMTP_USER (your SMTP login address). +ADMIN_NOTIFY_EMAIL="" + +# Optional — lets the "Share via Messenger" button work. See README's +# "Sharing via Messenger" section for how to get this. Leave blank to hide that button +# (WhatsApp sharing works with no setup either way). +NEXT_PUBLIC_FACEBOOK_APP_ID="" + +# Cloudflare Turnstile — CAPTCHA on the custom-request, registration, newsletter, +# and contact forms. Get a free site key + secret at https://dash.cloudflare.com +# (Turnstile section). Leave both blank to skip CAPTCHA entirely — forms work +# normally, just unprotected. +NEXT_PUBLIC_TURNSTILE_SITE_KEY="" +TURNSTILE_SECRET_KEY="" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b151815 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +.env +.env.local +prisma/dev.db +prisma/dev.db-* +*.log +.DS_Store +.claude/settings.local.json +dist/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3b66410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..610f866 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,127 @@ +# Craft2Prints — Handoff Brief + +This project was built in a series of conversations with Claude (chat interface, +zip-file delivery). The person is moving to **Claude Code** to work on it directly. +This doc summarizes where things stand so nothing gets lost in the switch. + +## What this is + +An e-commerce site with two parallel catalogs: +- **Personalised** (`/made-to-order`) — customers design their own version of a + product using an in-browser design tool (drag text/images onto the product). +- **Products** (`/products`) — same underlying products, but a plain "pick a + color/size, buy as-is" page, no design tool. + +A single product can be flagged to appear on either catalog, or both, via +`showOnPersonalised` / `showOnProducts` booleans — same photos either way, nothing +duplicated. + +Business context: UK-based, GBP is the base/canonical currency, sources some +products from a supplier called Firelabel. + +## Tech stack + +- Next.js 14 (App Router), TypeScript, Tailwind CSS +- Prisma + SQLite (`prisma/dev.db`) — easy to swap to Postgres later by changing + `provider` in `prisma/schema.prisma` and `DATABASE_URL` +- Zustand for cart state, persisted to **IndexedDB** (not localStorage — see + Gotchas below for why) +- Konva / react-konva for the design tool canvas +- Stripe for checkout +- Nodemailer for design-approval emails (optional, SMTP-based) + +## Person's environment (important context) + +- **Windows**, project lives at `D:\Craft2Prints` +- Dev server has repeatedly ended up on **port 3001** instead of 3000 because a + stale `node.exe` process keeps holding port 3000 / locking Prisma's engine + binary. Recurring fix: Task Manager → kill all `node.exe` → re-run + `npx prisma generate`. If that keeps happening, it may be worth just settling on + 3001 as the working port rather than fighting it every time. +- Was previously working **inside OneDrive**, which caused `.next` build errors + (`EINVAL: invalid argument, readlink`) — project was moved out of OneDrive to fix + this. If it ever moves again, keep it outside any cloud-sync folder. +- Has hit `Watchpack Error` noise on Windows a few times — `next.config.mjs` is + already configured to fall back to polling-based file watching, which resolved it. +- Not deeply technical with the terminal — needs exact copy-pasteable commands, not + assumed familiarity with npm/Prisma/Stripe CLI workflows. + +## What's built and working + +- Full product management: add/edit/delete, admin-managed categories (each + scopable to either catalog), admin-managed sizes, colors as swatches (not hex + text), real photo upload with a drag-to-mark print-area box, optional back + photo (own print area), photo-per-color (front AND back), and an optional + black/white photo "tint" recolor mode as a fallback when there's no per-color + photo. +- The design tool: front/back personalization, drag/resize/rotate text and + uploaded images, 11 font choices, color swatches, size picker. Exports two + images per side: a clean transparent print-ready PNG, and a "placement + reference" image (design composited onto the actual product photo, for print + placement — only generated for photo-based products, not illustration-only + ones). +- Cart (`/cart`), real Stripe Checkout, webhook-confirmed order persistence, + admin order management (`/admin/orders`) with print files downloadable per + order, and order archiving (manual button + auto-archive after 30 days). +- Custom design-approval flow for one-off jobs: admin uploads a finished design + for a specific customer (`/admin/proofs/new`), customer reviews/approves via a + link, with an in-page chat thread and optional email notification. +- Currency selector (GBP/USD/EUR) — display only, fixed approximate rates, not + live; actual checkout always charges in GBP regardless of what's displayed. +- Dark mode, admin area behind HTTP Basic Auth (`ADMIN_USER`/`ADMIN_PASSWORD` in + `.env`). + +## Known gaps / explicitly not built yet + +- **Order confirmation emails** — customer only sees the on-screen success page; + no automated email receipt yet. +- **"Buy it now"** — everything currently goes through the cart, no skip-to-checkout + for a single item. +- **`/products` catalog** has no illustration fallback for placement references — + only real uploaded photos get a placement-reference image. +- Checkout's `shipping_address_collection` allowed countries is a short hardcoded + list in `src/app/api/checkout/route.ts` — worth revisiting for the actual + shipping footprint. +- No multi-currency *charging* — only display formatting. Stripe currently always + charges GBP. + +## Gotchas specific to this codebase + +- **Environment/session resets happened at least once during the build** — a chunk + of work (categories, sizes, product editing) briefly vanished from the working + copy and had to be reconstructed from conversation history. It was fully + recovered, but if anything ever seems to have "reverted" unexpectedly, grep for + the feature in question before assuming it was never built — it may just need + re-syncing rather than rebuilding from scratch. +- **`prisma/seed.ts` is idempotent and safe to re-run** — it only touches the 6 + starter products (matched by slug) and 3 starter categories, never anything + added through the admin panel. Useful for resetting demo data without wiping + real work. +- **Cart storage**: moved from `localStorage` to IndexedDB after a real + `QuotaExceededError` crash — each cart item can carry up to 4 full-size images + (print files + placement refs), which blew past localStorage's ~5-10MB cap with + just 2-3 items. Don't move this back to localStorage. +- **The image-node-ready race condition**: uploaded images in the design tool load + asynchronously, unlike text. There's a `nodeReadyTick` mechanism in + `DesignCanvas.tsx` specifically to re-sync the resize-handle Transformer once an + image's Konva node actually mounts. If resize handles ever stop appearing on + images again, this is the first place to check — and watch out for calling a + state setter unconditionally inside an inline Konva `ref` callback, since that + callback re-fires on every render (this caused an infinite-loop bug once + already; the fix was guarding on whether the node reference actually changed). + +## Setup (for a fresh Claude Code session to verify environment) + +```bash +npm install +cp .env.example .env # fill in Stripe keys, admin password, etc. — see README +npx prisma generate +npm run db:push +npm run db:seed +npm run dev +``` + +Full details, including Stripe test-mode setup with the Stripe CLI, are in +`README.md` at the project root — it's been kept up to date throughout the build +and is the more detailed reference; treat this file as the high-level orientation +and README.md as the how-to. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a1b259 --- /dev/null +++ b/README.md @@ -0,0 +1,491 @@ +# Craft2Prints + +A personalized e-commerce site: browse templates (apparel, drinkware, phone cases), +customize them with your own text/images in an in-browser design tool, and check out +with real Stripe payments. Built with Next.js (App Router), Prisma, and Stripe. + +## Sharing via WhatsApp / Messenger + +Instead of (or alongside) email, the confirmation screen after uploading a design +gives you two direct share buttons: + +- **WhatsApp** works immediately, no setup — opens WhatsApp with the message and + link pre-filled, you just pick who to send it to. +- **Messenger** needs a one-time setup: create a free app at + https://developers.facebook.com/apps, add your site's domain under the app's + settings, copy the App ID, and set `NEXT_PUBLIC_FACEBOOK_APP_ID` in `.env`. Until + that's set, the Messenger button just won't appear — WhatsApp sharing still works + either way. + +## Important: testing links on your phone + +Right now `NEXT_PUBLIC_SITE_URL` is `http://localhost:3000`, which only works +**on the same computer that's running `npm run dev`**. If you open a design-approval +link on your phone or another device, it will fail to load — `localhost` always +means "this device," never your computer over the network. Some phones/carriers +redirect failed addresses like that to random landing pages, which can look like +a broken or suspicious link. + +This isn't a bug to fix — it resolves itself once the site is deployed to a real +domain (later, when we cover deployment). Until then, only test approval links on +the same computer running the dev server. + +## Sending emails + +To have the approval link emailed to the customer automatically (instead of just +copying it yourself), add SMTP credentials to `.env`: + +``` +SMTP_HOST="smtp.gmail.com" +SMTP_PORT="587" +SMTP_USER="youraddress@gmail.com" +SMTP_PASSWORD="your app password" +MAIL_FROM="Craft2Prints " +``` + +For Gmail, `SMTP_PASSWORD` needs to be an **app password**, not your normal login +password — generate one at https://myaccount.google.com/apppasswords (requires +2-factor authentication to be turned on). Most other providers (Outlook, a business +email host, SendGrid, etc.) work the same way — check their SMTP settings page. + +If `SMTP_HOST` is left blank, nothing breaks — the design still gets a shareable +link, you just have to copy and send it yourself instead of it emailing automatically. + +If you upload a photo, drag the pink box that appears over it to mark exactly where +the design tool should let customers place their text/artwork — no more relying on +a generic preset. The preview crops to a square (matching how the design tool +displays it), so the box you draw lines up correctly for the customer later. + +Optionally, upload a real photo instead of using the illustration. Worth knowing: +the color swatches recolor the illustration live, but a photo is fixed — picking a +different color still gets saved with the order, it just won't change the picture. +Leave the photo field blank to keep the illustration and its live color-recoloring. + +#### Personalizing the back too + +If you set a back photo, customers get a Front/Back toggle on the design page and +can add a completely separate design to each side — their own text/images on the +front, different ones on the back. When you upload the back photo, drag a print-area +box for it just like the front (defaults to the same box as the front if you skip +this). "Add to bag" captures both sides. + +## Real photo per color + +Each color's photo slot now has both a **Front** and **Back** upload — the "Photo per +color" section shows both side by side. Attach a back photo for a color and it shows +correctly when a customer views that color's back (falls back to the product's main +back photo, or the illustration, if left blank). + +For the most accurate look, attach an actual photo for specific colors instead of +relying on the tint. Once you've added colors above, a "Photo per color" section +appears — attach a file to any color and that exact photo is shown the instant a +customer picks that color on the design page. Colors left blank fall back to the +front photo (tinted or fixed) or the illustration. This is the best option when you +have real supplier/product photography for each color — no tinting compromise, just +the true photo. + +## Two separate product pages now + +- `/made-to-order/[slug]` — the full design tool (only reachable for products with + "Personalised catalog" checked) +- `/products/[slug]` — a plain "pick a color/size, buy as-is" page, no design tool, + no print-area box (only reachable for products with "Products catalog" checked) + +A product checked for both catalogs shows the design tool when reached via +`/made-to-order` and the plain buy-as-is page when reached via `/products` — same +photos and colors either way, just a different experience depending on which +catalog someone came from. + +## Listing a product on both catalogs + +On the product form there's a "Show on" section with two checkboxes: + +- **Personalised catalog** (`/made-to-order`) — customers design their own version +- **Products catalog** (`/products`) — same photos, browse and buy as-is + +Check either one or both. The same product record — same photos, colors, price — +can appear on both pages at once; nothing needs to be duplicated. `/products` now +has its own real search/category/color/price filters, same as `/made-to-order`, just +scoped to whichever products have that checkbox on. Each links to its own kind of +detail page — see "Two separate product pages" above. + +## Currency + +Prices are stored as an integer in **GBP pence** — that's the canonical currency for +the whole site. A currency selector in the header (£ GBP / $ USD / € EUR) lets +customers switch the *display* currency; it remembers their choice in the browser. + +Important: the conversion rates in `src/lib/currency.ts` are fixed approximations, +not live exchange rates, and this only changes what's *displayed* — there's no +checkout yet, so no real payment currency conversion happens. Update those rates +periodically if you want displayed foreign-currency prices to stay roughly current, +and revisit this properly once Stripe checkout is built (Stripe can charge in +whichever currency you configure, which may or may not want to match what's shown +here). + +Admin pages (product forms, proofs, order management) always show plain £ — they're +managing the actual stored value, not a customer-facing display, so they're not tied +to the selector. + +## Cart storage + +The cart is persisted to **IndexedDB**, not `localStorage`. This matters because +each personalized item can carry up to 4 full-size images (print files + placement +references) — that adds up fast, and `localStorage` has a hard ~5-10MB cap per site +that a cart with even 2-3 custom items can blow past, causing a hard crash +(`QuotaExceededError`) right when someone tries to check out. IndexedDB doesn't have +that low ceiling, so this is a real fix, not a workaround. + +One thing worth knowing as the cart/checkout evolves further: the checkout request +itself sends the full cart (images included) from the browser to the server in one +go. That's fine at normal sizes, but if you ever see checkout failing specifically +with very large/many-image carts, that request payload size is the next place to +look — some hosts cap request body size (this isn't an issue on a normal self-hosted +Node server, but would be on some serverless platforms). + +## Archiving orders + +Paid or failed orders older than 30 days archive automatically — this happens as a +sweep each time you open Admin → Orders, not a background job, but it's effectively +invisible either way. You can also archive any order manually right away with the +button on its row or its detail page, no need to wait. Archived orders move out of +the main list; view them at Admin → Orders → "View archived," with an Unarchive +button to bring one back. Orders still `PENDING` are never auto-archived, only +`PAID` or `FAILED` ones. + +## Checkout — setting it up + +Checkout is now real: the cart page creates an Order in the database, sends the +customer to Stripe's hosted payment page, and a webhook marks the order paid once +Stripe confirms it. Here's what you need to actually make it work: + +### 1. Get Stripe test keys + +Sign up / log in at https://dashboard.stripe.com, make sure you're in **test mode** +(toggle top-right), then go to Developers → API keys. Copy the **Secret key** +(`sk_test_...`) into `.env`: + +``` +STRIPE_SECRET_KEY="sk_test_..." +``` + +### 2. Set up the webhook for local testing + +Stripe needs to reach your webhook endpoint, but `localhost` isn't reachable from +the internet. For local development, use the Stripe CLI: + +1. Install it: https://docs.stripe.com/stripe-cli (or `brew install stripe/stripe-cli/stripe` on Mac) +2. Run `stripe login` once +3. While your dev server is running, in another terminal: `stripe listen --forward-to localhost:3000/api/webhook` +4. It'll print a webhook signing secret starting `whsec_...` — copy that into `.env`: +``` +STRIPE_WEBHOOK_SECRET="whsec_..." +``` +5. Keep that `stripe listen` command running in the background whenever you're testing checkout locally — it forwards Stripe's events to your local server. + +(Once deployed to a real domain, you'd instead create a proper webhook endpoint in +the Stripe Dashboard pointing at `https://yourdomain.com/api/webhook` and use that +endpoint's signing secret instead — the CLI approach is just for local dev.) + +### 3. Test a payment + +Add something to your bag, go to `/cart`, click Checkout. On Stripe's page, use a +test card: `4242 4242 4242 4242`, any future expiry date, any 3-digit CVC, any +postcode. It'll redirect to `/checkout/success`, and — assuming `stripe listen` is +running — the order should show as **PAID** within a second or two. Check +Admin → Orders to see it, download the print-ready PNGs, etc. + +### 4. Going live later + +When you're ready for real payments: switch to your live Stripe keys (toggle out of +test mode in the dashboard), set up a real webhook endpoint in the Stripe Dashboard +for your live domain, and update `.env` with the live secret key and that webhook's +signing secret. Also worth adding: order confirmation emails to the customer (not +built yet — currently they only see the on-screen confirmation and whatever Stripe +itself emails them), and probably restricting `shipping_address_collection` in +`src/app/api/checkout/route.ts` to just the countries you actually ship to. + +## Print-ready design files + +The exported design image (used for the cart preview, and downloadable from the +cart page and from Admin → Orders) is just the customer's artwork — transparent +background, no shirt, no print-area guide marks baked in. Every order's items show +"Download front PNG" / "Download back PNG" links on its Admin → Orders detail page, +so once a real order comes in, that's where to get the print files from. + +**Two images per item, two different jobs:** alongside that clean print file, there's +also a "placement reference" image — the design shown composited on the actual +product photo, so you can see exactly where it sits before you print it. That's the +larger thumbnail shown on the order detail page (and on the cart/success pages); +the download links next to it are the clean, print-only files. For products using +the illustration (no real photo uploaded), there's no placement reference to show — +only real photos can be composited this way. + +## Adding products + +Go to Admin → Add product (or `/admin/products/new`). Fill in the name, category +(pulled from your manageable category list — see below), price, and colors (click a +swatch to add it, no hex-typing needed). Pick a template shape (t-shirt, hoodie, mug, +tumbler, or phone case) to use the built-in illustration, or upload your own front — +and optionally back — photo instead. It goes straight into the personalized catalog +at `/made-to-order`. + +Manage what's there at `/admin/products` — view, **edit** (change anything, including +swapping the photo or colors later), or delete. Deleting is blocked if a product +already has orders or design proofs tied to it, to avoid orphaning that history. + +### Recoloring a photo (front/back + live color tint) + +If you upload a photo, drag the pink box that appears over it to mark exactly where +the design tool should let customers place their text/artwork. You can also add a +**back photo** — customers get a Front/Back toggle (the design tool only applies to +the front). + +Check **"Let the color swatches recolor this photo"** to have the color picker +actually retint the photo live, instead of just being a label. This only looks good +for photos that are black, white, or gray — the color is applied as a multiply-blend +tint, so a photo that's already colored (like a red mug) will just look muddy if you +turn this on. For black-photo products specifically: expect punchy mid-tone colors +(oranges, blues, greens) to look great, but very light colors (white, pastels) will +come out as a muted gray rather than true white — that's a real limit of tinting a +photo that has no bright highlight data to begin with, not a bug. If you need a true +white variant to look right, upload a separate white-photo product instead of relying +on the tint. + +## Scoping categories to a catalog + +Categories can now be scoped too, same idea as products: on Admin → Add category, +two checkboxes control where a category shows up as a filter — Personalised +(`/made-to-order`), Products (`/products`), or both (the default). Toggle these on +existing categories from Admin → All categories. This only affects which catalog's +filter sidebar shows the category — it doesn't restrict which products can use it. + +## Managing categories + +Categories are managed from Admin → Add category / All categories, not hardcoded. A +new category shows up immediately in: the product form's category dropdown, the +shop's filter sidebar, and the "Personalised" menu in the header. Deleting a category +is blocked if any product is still using it. + +## Sizes + +On the product form, add sizes via the preset buttons (XS–XXL) or type a custom one +(for anything non-apparel-shaped, like "One Size" or a numeric size) and hit Enter. +Leave it empty for products that don't need sizing — mugs, phone cases, etc. When a +product has sizes, a size picker automatically appears on its design page, and the +chosen size is saved with the cart item. + +This only applies to the personalized catalog (`/made-to-order`) for now — the +ready-made catalog (`/products`) is still just a placeholder page, so there's nowhere +for a size picker to live yet. + +## Dark mode + +There's a toggle in the header (sun/moon icon) that switches between light and dark. +It respects the visitor's system preference on first visit, then remembers whatever +they pick in `localStorage` after that. Most of the site flips automatically since +the color system is built on a handful of tokens (background, text, borders, card +surfaces) that swap together — new pages you add will pick this up for free as long +as they use the existing `bg-paper` / `text-ink` / `border-line` / `bg-surface` +classes rather than hardcoded colors like `bg-white`. + +## Chat with customers + +Each design proof has its own chat thread. The customer sees it right on their +approval page (`/proofs/[id]`) and can ask for changes without emailing back and +forth. You see and reply to every conversation at `/admin/inbox`. + +It's polling-based (checks for new messages every few seconds) rather than true +real-time — simple and reliable to self-host, just a couple seconds of lag instead +of instant delivery. The initial email still goes out when you upload a design, so +the customer gets pinged even if they're not looking at the page; the chat is for +the conversation that follows. + +## Custom design approvals (you upload, customer approves) + +For one-off custom jobs — you design a T-shirt yourself and want a specific customer +to review and buy it — there's a separate flow from the self-serve design tool: + +1. Go to `http://localhost:3000/admin/proofs/new` (password-protected, see below) +2. Pick the product template, enter the customer's email, upload your finished + design image, set color/quantity/price +3. You'll get a shareable link like `/proofs/abc123` +4. Send that link to the customer any way you like (email, text) +5. They open it, see the design, and click "Approve & add to bag" — it goes straight + into their cart, ready for checkout + +See everything you've sent at `/admin/proofs`. + +**Change the admin password before deploying.** The `/admin` pages are protected by a +login page at `/admin/login`, checked against `ADMIN_USER` / `ADMIN_PASSWORD` in `.env`. +The defaults (`admin` / `changeme`) are fine for local testing but must be changed +before this site is reachable from the internet. Once logged in, "Log out" is in the +Admin dropdown in the header. This uses its own session cookie, signed with +`ADMIN_SESSION_SECRET` — **change that to a random value before deploying too** (a +fresh one has already been generated into your local `.env`). + +## Customer photo requests (they upload, you turn it into a design) + +The other direction from the flow above — a customer can send you their own photo +for you to work with, instead of you already having a finished design ready: + +1. They visit `/custom-request`, pick a product, upload their photo, and leave their + contact info (name, email, optional phone) and a note +2. You get an email alert (see "Sending emails" — same SMTP setup) and can browse all + submissions at `/admin/custom-requests` +3. Open a submission, and once you've worked on it, click "Start a design for this" — + it takes you straight to `/admin/proofs/new` with the product/customer details + pre-filled, so you just upload the finished result and send it back through the + normal proof-approval flow above +4. Each submission also has a one-click WhatsApp/Messenger button (opens pre-filled, + for you to send/forward manually) and, if they left a phone number, a direct + "Message on WhatsApp" link addressed to them + +Set `ADMIN_NOTIFY_EMAIL` in `.env` to choose where new-submission alerts go — leave it +blank and it defaults to your `SMTP_USER` address. + +## Customer accounts + +Separate from the admin login above, customers can create their own accounts to see +their order history: + +- `/account/register` and `/account/login` — sign up / sign in +- `/account` — order history (only orders placed while logged in, plus any past guest + orders that used the same email — those get linked in automatically the first time + someone registers or logs in) +- `/account/forgot-password` — emails a reset link (uses the same SMTP setup as + design-approval emails, see "Sending emails" above; if SMTP isn't configured you'll + just see a generic confirmation with nothing actually sent) + +Checkout still works without an account — customer accounts are optional, not +required to buy. + +This uses its own login cookie (`SESSION_SECRET` in `.env`), completely separate from +the admin `ADMIN_USER`/`ADMIN_PASSWORD` — logging into one never grants access to the +other. **Change `SESSION_SECRET` to a random value before deploying**, same as the +admin password (a fresh one has already been generated into your local `.env`, so +this only matters when you deploy somewhere new). + +## Security: rate limiting & CAPTCHA + +- **Login rate limiting** works with no setup — customer login (`/account/login`), + admin login (`/admin/login`), and the checkout API are all limited per IP address + (5 attempts / 15 min for logins, 20 requests / 10 min for checkout), backed by the + database so it works the same on a single server or serverless. Nothing to configure. +- **CAPTCHA** (Cloudflare Turnstile) protects the custom-request, account registration, + newsletter signup, and contact forms — but only once you set `NEXT_PUBLIC_TURNSTILE_SITE_KEY` + and `TURNSTILE_SECRET_KEY` in `.env`. Get a free site key + secret at + [dash.cloudflare.com](https://dash.cloudflare.com) under Turnstile. Until those are + set, the forms work normally with no CAPTCHA shown — it's opt-in, not required to + run the site. + +## What's built so far + +- Homepage (`/`) with a split path between Personalised and Products +- Personalized product catalog with search, category, color, and price filters (`/made-to-order`) +- Product detail page with the design tool, reviews, and related products (`/made-to-order/[slug]`) +- Full product management: add, edit, delete, with admin-managed categories and sizes (`/admin/products`, `/admin/categories`) +- Custom design approval flow (`/admin/proofs/new`, `/admin/proofs`, `/proofs/[id]`) +- Per-design chat between you and the customer (`/admin/inbox`) +- Dark mode +- Cart page (`/cart`) — view, adjust quantity, remove items, see total +- Real checkout via Stripe, order persistence, and admin order management (`/admin/orders`) + +Still to come: order confirmation emails, and a "buy it now" flow that skips +straight to checkout for a single item (right now everything goes through the bag). + +## Updating an existing install + +Already had this running before? After pulling in new files: + +```bash +npx prisma generate +npm run db:push +npm run db:seed +``` + +`db:push` is safe to re-run — it only adds what's changed (new tables/columns), it +won't wipe your existing data. `db:seed` is also safe to re-run: it only touches the +6 starter products that ship with this repo (matched by their slug — classic-tee, +heavyweight-hoodie, etc.), syncing them to whatever's currently in `prisma/seed.ts`. +Any products or categories you've added yourself through the admin panel are never +touched by it. Also check `.env.example` for any new variables and add them to your +own `.env`. + +## 1. Install prerequisites + +You need **Node.js 18 or later**. Check with: + +```bash +node -v +``` + +If you don't have it, install it from https://nodejs.org (the LTS version is fine). + +## 2. Install dependencies + +From the project folder (where this README lives): + +```bash +npm install +``` + +This will also automatically run `prisma generate` (via the `postinstall` script). + +## 3. Set up your environment file + +Copy the example file: + +```bash +cp .env.example .env +``` + +Open `.env` and fill in: +- `DATABASE_URL` — leave as `file:./dev.db` for now (SQLite, zero config) +- `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` — get these later from + https://dashboard.stripe.com/apikeys once we build checkout. Not needed yet to run + the homepage/catalog. +- `NEXT_PUBLIC_SITE_URL` — `http://localhost:3000` for local dev + +## 4. Create the database and load starter products + +```bash +npm run db:push +npm run db:seed +``` + +`db:push` creates the SQLite database file from `prisma/schema.prisma`. +`db:seed` loads the 6 starter products (tee, hoodie, mug, tumbler, 2 phone cases). + +## 5. Run it + +```bash +npm run dev +``` + +Open http://localhost:3000 in your browser. You should see the homepage; click +"Shop all" to see the catalog page with filters. + +Any time you change code, the page auto-reloads. If you change `prisma/schema.prisma`, +re-run `npm run db:push`. + +## Troubleshooting + +- **"Cannot find module '@prisma/client'"** — run `npx prisma generate` manually. +- **Port 3000 already in use** — stop whatever else is running on that port, or run + `npm run dev -- -p 3001` to use a different port. +- **Blank product grid** — make sure you ran `npm run db:seed`. + +## Deploying to your own server later + +This app is a standard Next.js app, so once it's finished it can run anywhere that +supports Node.js: + +```bash +npm run build +npm run start # serves on port 3000 by default +``` + +Put it behind nginx (or similar) as a reverse proxy, and use a process manager like +`pm2` to keep it running. We'll cover this in detail once the whole app is built and +ready to deploy. diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..a4c3c13 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + eslint: { ignoreDuringBuilds: true }, + experimental: { + serverComponentsExternalPackages: ['@prisma/client'], + // Invoice uploads (phone photos/PDFs) and product photo uploads go through + // server actions — the 1MB default rejects typical phone photos. + serverActions: { bodySizeLimit: '10mb' }, + }, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4918767 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2686 @@ +{ + "name": "personalize-studio", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "personalize-studio", + "version": "1.0.0", + "hasInstallScript": true, + "dependencies": { + "@prisma/client": "5.16.1", + "bcryptjs": "^3.0.3", + "idb-keyval": "6.2.1", + "jose": "^6.2.3", + "konva": "9.3.14", + "next": "14.2.5", + "nodemailer": "6.9.14", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-konva": "18.2.10", + "stripe": "16.2.0", + "uuid": "9.0.1", + "zustand": "4.5.4" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "20.14.9", + "@types/nodemailer": "6.4.15", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "@types/uuid": "9.0.8", + "autoprefixer": "10.4.19", + "postcss": "8.4.39", + "prisma": "5.16.1", + "tailwindcss": "3.4.4", + "tsx": "4.16.2", + "typescript": "5.5.3" + } + }, + "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/@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": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", + "integrity": "sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz", + "integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz", + "integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz", + "integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz", + "integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz", + "integrity": "sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz", + "integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz", + "integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz", + "integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz", + "integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "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.16.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.16.1.tgz", + "integrity": "sha512-wM9SKQjF0qLxdnOZIVAIMKiz6Hu7vDt4FFAih85K1dk/Rr2mdahy6d3QP41K62N9O0DJJA//gUDA3Mp49xsKIg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.16.1.tgz", + "integrity": "sha512-JsNgZAg6BD9RInLSrg7ZYzo11N7cVvYArq3fHGSD89HSgtN0VDdjV6bib7YddbcO6snzjchTiLfjeTqBjtArVQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.16.1.tgz", + "integrity": "sha512-KkyF3eIUtBIyp5A/rJHCtwQO18OjpGgx18PzjyGcJDY/+vNgaVyuVd+TgwBgeq6NLdd1XMwRCI+58vinHsAdfA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.16.1", + "@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "@prisma/fetch-engine": "5.16.1", + "@prisma/get-platform": "5.16.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303.tgz", + "integrity": "sha512-HkT2WbfmFZ9WUPyuJHhkiADxazHg8Y4gByrTSVeb3OikP6tjQ7txtSUGu9OBOBH0C13dPKN2qqH12xKtHu/Hiw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.16.1.tgz", + "integrity": "sha512-oOkjaPU1lhcA/Rvr4GVfd1NLJBwExgNBE36Ueq7dr71kTMwy++a3U3oLd2ZwrV9dj9xoP6LjCcky799D9nEt4w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.16.1", + "@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "@prisma/get-platform": "5.16.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.16.1.tgz", + "integrity": "sha512-R4IKnWnMkR2nUAbU5gjrPehdQYUUd7RENFD2/D+xXTNhcqczp0N+WEGQ3ViyI3+6mtVcjjNIMdnUTNyu3GxIgA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.16.1" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.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/node": { + "version": "20.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", + "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/nodemailer": { + "version": "6.4.15", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", + "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, + "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/autoprefixer": { + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "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.23.0", + "caniuse-lite": "^1.0.30001599", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "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/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/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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/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/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==", + "license": "MIT" + }, + "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/electron-to-chromium": { + "version": "1.5.388", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", + "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", + "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-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/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/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/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "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==", + "dev": true, + "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/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "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/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/idb-keyval": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==", + "license": "Apache-2.0" + }, + "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/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.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/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/konva": { + "version": "9.3.14", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.14.tgz", + "integrity": "sha512-Gmm5lyikGYJyogKQA7Fy6dKkfNh350V6DwfZkid0RVrGYP2cfCsxuMxgF5etKeCv7NjXYpJxKqi1dYkIkX/dcA==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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/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": "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": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz", + "integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.5", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.5", + "@next/swc-darwin-x64": "14.2.5", + "@next/swc-linux-arm64-gnu": "14.2.5", + "@next/swc-linux-arm64-musl": "14.2.5", + "@next/swc-linux-x64-gnu": "14.2.5", + "@next/swc-linux-x64-musl": "14.2.5", + "@next/swc-win32-arm64-msvc": "14.2.5", + "@next/swc-win32-ia32-msvc": "14.2.5", + "@next/swc-win32-x64-msvc": "14.2.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "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.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nodemailer": { + "version": "6.9.14", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz", + "integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==", + "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/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "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/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.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "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.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "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-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/prisma": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.16.1.tgz", + "integrity": "sha512-Z1Uqodk44diztImxALgJJfNl2Uisl9xDRvqybMKEBYJLNKNhDfAHf+ZIJbZyYiBhLMbKU9cYGdDVG5IIXEnL2Q==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.16.1" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "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": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-konva": { + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz", + "integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.1.1", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "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/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "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/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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/stripe": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-16.2.0.tgz", + "integrity": "sha512-kNHb5x6mlQJ8V92Afn+F+uyHG3U0+Mc1pOYxDkbEh/kI3oF63URnUt6dwTQthELYWBy04arerm2XjwgtzEPDEA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "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" + }, + "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.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", + "integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config/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/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/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/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/tsx": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.2.tgz", + "integrity": "sha512-C1uWweJDgdtX2x600HjaFaucXTilT7tgUZHbOE4+ypskZ1OP8CRCSDkCxG6Vya9EwaFIVagWwpaVAn5wzypaqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.21.5", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "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/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.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/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zustand": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.4.tgz", + "integrity": "sha512-/BPMyLKJPtFEvVL0E9E9BTUM63MNyhPGlvxk1XjrfWTUlV+BR8jufjsovHzrtR6YNcBEcL7cMHovL1n9xHawEg==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6b6ffeb --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "personalize-studio", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "prisma generate && next build", + "start": "next start -p 3000", + "postinstall": "prisma generate", + "db:push": "prisma db push", + "db:seed": "tsx prisma/seed.ts", + "db:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "5.16.1", + "bcryptjs": "^3.0.3", + "idb-keyval": "6.2.1", + "jose": "^6.2.3", + "konva": "9.3.14", + "next": "14.2.5", + "nodemailer": "6.9.14", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-konva": "18.2.10", + "stripe": "16.2.0", + "uuid": "9.0.1", + "zustand": "4.5.4" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "20.14.9", + "@types/nodemailer": "6.4.15", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "@types/uuid": "9.0.8", + "autoprefixer": "10.4.19", + "postcss": "8.4.39", + "prisma": "5.16.1", + "tailwindcss": "3.4.4", + "tsx": "4.16.2", + "typescript": "5.5.3" + } +} 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/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..d29cd8b --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,367 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + // Swap to "postgresql" for production scale — just change provider + DATABASE_URL. + provider = "sqlite" + url = env("DATABASE_URL") +} + +// Categories are their own table so you can add/rename/remove them from the admin +// panel. Product.category stays a plain string (matching Category.slug) rather +// than a foreign key, so nothing breaks if a category is renamed after the fact. +model Category { + id String @id @default(cuid()) + name String // display label, e.g. "Phone Cases" + slug String @unique // stored key, e.g. "PHONE_CASE" — matches Product.category + showOnPersonalised Boolean @default(true) // appears as a filter on /made-to-order + showOnProducts Boolean @default(true) // appears as a filter on /products + createdAt DateTime @default(now()) +} + +model Product { + id String @id @default(cuid()) + slug String @unique + name String + category String + description String + basePrice Int // cents — used for ready-made items on /products + personalisedPrice Int? // cents — used for made-to-order items on /made-to-order; falls back to basePrice if not set + colors String // JSON array of hex strings, e.g. ["#17181C","#FBF9F5"] + colorPhotos String? // JSON object mapping hex -> photo data URL, e.g. {"#17181C":"data:..."}. + colorPhotosBack String? // same idea, for the back view of each color + // Lets a product show the real photo for whichever color is selected, instead of + // tinting one photo. Falls back to imageUrl/photoRecolorable/mockup for any color + // that doesn't have its own photo here. + sizes String? // JSON array of size labels, e.g. ["S","M","L","XL"] — leave null for products with no sizing (mugs, phone cases) + mockup String // which SVG mockup component to render, e.g. "tshirt" | "hoodie" | "mug" | "phonecase" + printArea String // JSON: {"xPct":..,"yPct":..,"wPct":..,"hPct":..} + printAreaBack String? // same shape, for the back print area — only used when imageUrlBack is set + imageUrl String? // optional real product photo — shown instead of the illustration when set + imageUrlBack String? // optional back-view photo, toggled alongside the front photo + photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale + // "shading map" and multiplied by the selected color swatch live in the browser, + // instead of being shown as a fixed image. Only looks right for black/white/gray + // source photos — recoloring an already-colored photo produces muddy results. + showOnPersonalised Boolean @default(true) // list on /made-to-order + showOnProducts Boolean @default(false) // list on /products — same product/photo can show on both + createdAt DateTime @default(now()) + + salePrice Int? // cents — set alongside a date window below to put this product on sale + saleStartsAt DateTime? // leave null to start the sale immediately + saleEndsAt DateTime? // leave null for an open-ended sale + + orderItems OrderItem[] + proofs DesignProof[] + customRequests CustomRequest[] + promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items" + collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers" + manualSaleItems ManualSaleItem[] + stockLevels StockLevel[] + purchaseItems PurchaseItem[] +} + +// Quantity on hand for one product/colour/size combination. Purchases increase +// it, paid website orders and manual sales decrease it. Info-only: counts can +// go negative (shown highlighted in admin as "your records are out of sync") +// and never block anything on the shop side. +model StockLevel { + id String @id @default(cuid()) + productId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + color String + size String @default("") // '' = product has no sizes — a real empty value, not NULL, so the unique index below actually dedupes (SQLite treats NULLs as distinct) + quantity Int @default(0) + updatedAt DateTime @updatedAt + + @@unique([productId, color, size]) +} + +// Stock coming in: one supplier invoice, entered by hand with the invoice +// file (PDF or photo) attached for record-keeping. Product lines add to +// StockLevel; material lines (ink, vinyl, packaging) are just logged — they +// have no running count, and will feed the expenses list in a later stage. +// Every Purchase auto-creates an Expense on creation. +model Purchase { + id String @id @default(cuid()) + supplierName String + purchasedAt DateTime @default(now()) // invoice date, editable — not entry time + invoiceDataUrl String? // the uploaded invoice itself, as a data URL + invoiceFileName String? + total Int // pence — sum of line totals, recomputed server-side + notes String? + createdAt DateTime @default(now()) + + items PurchaseItem[] + expense Expense? // auto-created on Purchase creation; cascade delete if Purchase is deleted +} + +model PurchaseItem { + id String @id @default(cuid()) + purchaseId String + purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade) + productId String? // null = loose material line (no stock tracking) + product Product? @relation(fields: [productId], references: [id]) + description String // product name snapshot, or the material description + color String? + size String? + quantity Int + unitCost Int // pence +} + +// Expenses: everything that goes out. Auto-created from purchases (category = +// "Stock purchases"), plus manual entries for postage, utilities, equipment, etc. +// Receipt can be the invoice file or a standalone attachment. Pence storage like +// everything else. +model Expense { + id String @id @default(cuid()) + date DateTime @default(now()) + description String + category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them + amount Int // pence + receiptDataUrl String? // uploaded receipt as data URL + receiptFileName String? + notes String? + purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one) + purchase Purchase? @relation(fields: [purchaseId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) +} + +// A design you (the shop owner) upload for one specific customer to review and buy — +// separate from the self-serve design tool. You create one of these per custom job, +// send the customer the link, and they approve it straight into their cart. +model DesignProof { + id String @id @default(cuid()) + productId String + product Product @relation(fields: [productId], references: [id]) + customerName String? + customerEmail String + color String + quantity Int @default(1) + unitPrice Int // cents — defaults to the product's base price if not overridden + imageDataUrl String // the finished design image you uploaded, shown as-is to the customer + note String? // optional message shown alongside the design + status String @default("PENDING") // PENDING | APPROVED + createdAt DateTime @default(now()) + + messages Message[] +} + +// One chat thread per design proof — simple polling-based chat, no websockets needed. +model Message { + id String @id @default(cuid()) + proofId String + proof DesignProof @relation(fields: [proofId], references: [id], onDelete: Cascade) + sender String // "ADMIN" | "CUSTOMER" + body String + createdAt DateTime @default(now()) +} + +model Order { + id String @id @default(cuid()) + stripeSessionId String? @unique + email String? + shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes + status String @default("PENDING") // PENDING | PAID | FAILED + archived Boolean @default(false) + total Int // cents + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + customerId String? // set when the buyer was logged in at checkout — null for guest orders + customer Customer? @relation(fields: [customerId], references: [id]) + + items OrderItem[] +} + +// A customer's own account — separate from the ADMIN_USER/ADMIN_PASSWORD admin login. +// Optional: checkout still works as a guest with no Customer record at all. +model Customer { + id String @id @default(cuid()) + email String @unique + passwordHash String + name String? + createdAt DateTime @default(now()) + + orders Order[] + passwordResetToken PasswordResetToken? + customRequests CustomRequest[] + manualSales ManualSale[] +} + +// A sale that didn't go through the website checkout — cash at a fair, a bank +// transfer arranged over Facebook, etc. Recorded by the admin from +// /admin/accounts so every sale (web + offline) lives in one ledger. +model ManualSale { + id String @id @default(cuid()) + soldAt DateTime @default(now()) // when the sale actually happened (editable, not just entry time) + buyerName String? // free text — a cash buyer doesn't need an account + buyerEmail String? + customerId String? // auto-linked when buyerEmail matches a Customer account + customer Customer? @relation(fields: [customerId], references: [id]) + paymentMethod String // CASH | BANK_TRANSFER + total Int // pence — sum of line totals, computed server-side on save + notes String? + createdAt DateTime @default(now()) + + items ManualSaleItem[] +} + +model ManualSaleItem { + id String @id @default(cuid()) + saleId String + sale ManualSale @relation(fields: [saleId], references: [id], onDelete: Cascade) + productId String? // null for free-text one-offs (e.g. "custom commission") + product Product? @relation(fields: [productId], references: [id]) + description String // product name snapshot, or the free text itself + color String? + size String? + quantity Int + unitPrice Int // pence +} + +// One active reset token per customer at a time — requesting a new one replaces it. +model PasswordResetToken { + id String @id @default(cuid()) + customerId String @unique + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) +} + +// A customer's own photo, submitted for the shop to design something with — +// separate from the self-serve design tool and from DesignProof (which is the +// finished result you send back once you've worked on it). No FK link between +// the two: turning a request into a proof is a one-time action, not a tracked relation. +model CustomRequest { + id String @id @default(cuid()) + customerId String? + customer Customer? @relation(fields: [customerId], references: [id]) + productId String + product Product @relation(fields: [productId], references: [id]) + customerName String + customerEmail String + customerPhone String? + imageDataUrl String // the photo they want us to work with + note String? + status String @default("NEW") // NEW | DONE + createdAt DateTime @default(now()) +} + +// A promotion: shown as a banner on every page while its date window is active, +// AND actually discounts prices — either every product ("ALL") or a hand-picked +// set ("SPECIFIC", via the products relation below). Kept as a list rather than +// a single settings row so past promotions aren't destroyed when you create a +// new one. Price discounts stack with a product's own manual sale price by +// taking whichever is lower — see getEffectivePrice in src/lib/pricing.ts. +model Promotion { + id String @id @default(cuid()) + message String + discountPercent Int // 1-100 + scope String @default("ALL") // ALL | SPECIFIC + startsAt DateTime? // leave null to start immediately + endsAt DateTime? // leave null for an open-ended promotion + createdAt DateTime @default(now()) + + products Product[] // only consulted when scope = SPECIFIC +} + +// Occasion ("Christmas", "Birthdays") and recipient ("Teachers", "School leavers") tags +// for the two nav dropdowns of the same name. One model with a group discriminator +// (rather than two models) so there's a single set of admin pages — split into +// sections by group instead. A product can carry any number of both, hence the +// many-to-many (unlike Product.category, which is a single string). +model Collection { + id String @id @default(cuid()) + name String + slug String @unique + group String // "OCCASION" | "RECIPIENT" + createdAt DateTime @default(now()) + products Product[] +} + +// Unified mailing list — fed by three separate sources (customer registration, +// the footer newsletter signup, and guest checkout emails) so a single admin +// broadcast can reach all of them without querying three different shapes. +// `subscribed` is the admin's opt-out switch and is deliberately never +// overwritten by a later upsert from any source, so it always wins. +model MailingListEntry { + id String @id @default(cuid()) + email String @unique + name String? + source String // "CUSTOMER" | "NEWSLETTER" | "GUEST" — where this address first came from + subscribed Boolean @default(true) + createdAt DateTime @default(now()) +} + +// Backs a small DB-based rate limiter (src/lib/rateLimit.ts) — chosen over an +// in-memory counter because that would silently reset on every serverless +// cold start and not share state across instances. Rows older than 24h are +// opportunistically pruned by the limiter itself, no separate cron needed. +model RateLimitAttempt { + id String @id @default(cuid()) + key String // e.g. "login:customer:", "checkout:" + createdAt DateTime @default(now()) + + @@index([key, createdAt]) +} + +// Site-wide settings — a single row (fixed id "singleton"). Currently just the +// maintenance-mode toggle: when on, customers see a "we're updating" page while +// logged-in admins still see the real site. Read on every page render (cheap +// single-row lookup), written only from the admin maintenance page. +model SiteSetting { + id String @id @default("singleton") + maintenanceMode Boolean @default(false) + maintenanceMessage String? +} + +// Footer email signup — just captures addresses for you to use once you pick an +// email tool (Mailchimp, Klaviyo, etc.). Not wired up to send anything itself. +model NewsletterSubscriber { + id String @id @default(cuid()) + email String @unique + createdAt DateTime @default(now()) +} + +// Photo gallery of completed work, shown on the public /gallery page. Photos are +// uploaded manually in the admin (base64 data URLs, same as products/proofs). +// Categories here are their own thing, separate from product Category — a photo's +// category is optional. (Future: an official Facebook Graph API import could add a +// nullable source/attribution column here — not built yet.) +model GalleryCategory { + id String @id @default(cuid()) + name String + slug String @unique + createdAt DateTime @default(now()) + photos GalleryPhoto[] +} + +model GalleryPhoto { + id String @id @default(cuid()) + imageDataUrl String // base64 data URL + caption String? + categoryId String? // nullable — a photo can be uncategorized + category GalleryCategory? @relation(fields: [categoryId], references: [id]) + createdAt DateTime @default(now()) +} + +model OrderItem { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + productId String + product Product @relation(fields: [productId], references: [id]) + productName String // snapshot at time of order, in case the product changes later + quantity Int + color String + size String? + unitPrice Int // cents + designJson String // serialized {front:[...], back:[...]} design elements + designPreviewUrl String // base64 PNG data URL — front design, transparent, print-ready + designPreviewUrlBack String? // same, for the back design if there is one + placementPreviewUrl String? // front design shown composited on the actual product photo, for reference — where does this go when printing + placementPreviewUrlBack String? // same, for the back +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..e71cf7b --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,166 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const categories = [ + { name: 'Apparel', slug: 'APPAREL' }, + { name: 'Drinkware', slug: 'DRINKWARE' }, + { name: 'Phone case', slug: 'PHONE_CASE' }, +]; + +const products: Array<{ + slug: string; + name: string; + category: string; + description: string; + basePrice: number; + colors: string[]; + sizes: string[]; + mockup: string; + printArea: { xPct: number; yPct: number; wPct: number; hPct: number }; + imageUrl: string | null; + imageUrlBack: string | null; + photoRecolorable: boolean; +}> = [ + { + slug: 'classic-tee', + name: 'Classic Tee', + category: 'APPAREL', + description: + 'Heavyweight 220gsm combed cotton. Your artwork, printed dead-center on the chest.', + basePrice: 2900, + colors: ['#FFFFFF', '#17181C', '#2B4C3F', '#8A8578', '#1EA7E0', '#E5227E', '#F5871F', '#5B2C86', '#7A1F1F', '#D8D2C2'], + sizes: ['S', 'M', 'L', 'XL', 'XXL'], + mockup: 'tshirt', + printArea: { xPct: 0.32, yPct: 0.27, wPct: 0.36, hPct: 0.34 }, + imageUrl: '/seed/tee-front.png', + imageUrlBack: '/seed/tee-back.png', + photoRecolorable: true, + }, + { + slug: 'heavyweight-hoodie', + name: 'Heavyweight Hoodie', + category: 'APPAREL', + description: 'Brushed-fleece interior, boxy fit. Built for a bold front print.', + basePrice: 5400, + colors: ['#17181C', '#8A8578', '#2B4C3F', '#7A1F1F', '#1EA7E0', '#D8D2C2', '#5B2C86'], + sizes: ['S', 'M', 'L', 'XL', 'XXL'], + mockup: 'hoodie', + printArea: { xPct: 0.33, yPct: 0.34, wPct: 0.34, hPct: 0.28 }, + imageUrl: null, + imageUrlBack: null, + photoRecolorable: false, + }, + { + slug: 'ceramic-mug', + name: 'Ceramic Mug', + category: 'DRINKWARE', + description: '11oz glossy ceramic. Dishwasher-safe, wraparound print.', + basePrice: 1800, + colors: ['#FFFFFF', '#17181C', '#1EA7E0', '#E5227E', '#F5871F', '#5B2C86'], + sizes: [], + mockup: 'mug', + printArea: { xPct: 0.22, yPct: 0.32, wPct: 0.56, hPct: 0.28 }, + imageUrl: null, + imageUrlBack: null, + photoRecolorable: false, + }, + { + slug: 'travel-tumbler', + name: 'Travel Tumbler', + category: 'DRINKWARE', + description: '16oz double-wall stainless steel, keeps drinks cold for 24 hours.', + basePrice: 3200, + colors: ['#17181C', '#8A8578', '#C9A227', '#1EA7E0', '#E5227E', '#2B4C3F'], + sizes: [], + mockup: 'tumbler', + printArea: { xPct: 0.26, yPct: 0.28, wPct: 0.48, hPct: 0.36 }, + imageUrl: null, + imageUrlBack: null, + photoRecolorable: false, + }, + { + slug: 'snap-phone-case', + name: 'Snap Phone Case', + category: 'PHONE_CASE', + description: 'Slim polycarbonate snap case with a matte finish.', + basePrice: 2200, + colors: ['#FFFFFF', '#17181C', '#2B4C3F', '#E5227E', '#1EA7E0', '#F5871F'], + sizes: [], + mockup: 'phonecase', + printArea: { xPct: 0.28, yPct: 0.16, wPct: 0.44, hPct: 0.5 }, + imageUrl: null, + imageUrlBack: null, + photoRecolorable: false, + }, + { + slug: 'tough-phone-case', + name: 'Tough Phone Case', + category: 'PHONE_CASE', + description: 'Dual-layer shock-resistant case with raised edges.', + basePrice: 2900, + colors: ['#17181C', '#8A8578', '#5B2C86', '#7A1F1F'], + sizes: [], + mockup: 'phonecase', + printArea: { xPct: 0.3, yPct: 0.18, wPct: 0.4, hPct: 0.46 }, + imageUrl: null, + imageUrlBack: null, + photoRecolorable: false, + }, + { + slug: 'shaker-bottle', + name: 'Shaker Bottle', + category: 'DRINKWARE', + description: '20oz insulated shaker bottle with a secure flip-top lid. Your artwork, printed on the body.', + basePrice: 1500, + colors: ['#FFFFFF'], + sizes: [], + mockup: 'tumbler', + printArea: { xPct: 0.32, yPct: 0.36, wPct: 0.36, hPct: 0.38 }, + imageUrl: '/seed/shaker-bottle.png', + imageUrlBack: null, + photoRecolorable: false, + }, +]; + +async function main() { + for (const c of categories) { + await prisma.category.upsert({ + where: { slug: c.slug }, + update: {}, + create: c, + }); + } + + for (const p of products) { + const data = { + name: p.name, + category: p.category, + description: p.description, + basePrice: p.basePrice, + colors: JSON.stringify(p.colors), + sizes: p.sizes.length > 0 ? JSON.stringify(p.sizes) : null, + mockup: p.mockup, + printArea: JSON.stringify(p.printArea), + imageUrl: p.imageUrl, + imageUrlBack: p.imageUrlBack, + photoRecolorable: p.photoRecolorable, + }; + await prisma.product.upsert({ + where: { slug: p.slug }, + update: data, // keeps these 6 starter products in sync when the seed file changes + create: { slug: p.slug, ...data }, + }); + } + + console.log(`Seeded ${categories.length} categories and ${products.length} products.`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..fd6f33e Binary files /dev/null and b/public/logo.png differ diff --git a/public/seed/shaker-bottle.png b/public/seed/shaker-bottle.png new file mode 100644 index 0000000..bdb5095 Binary files /dev/null and b/public/seed/shaker-bottle.png differ diff --git a/public/seed/tee-back.png b/public/seed/tee-back.png new file mode 100644 index 0000000..37e61dd Binary files /dev/null and b/public/seed/tee-back.png differ diff --git a/public/seed/tee-front.png b/public/seed/tee-front.png new file mode 100644 index 0000000..7edc521 Binary files /dev/null and b/public/seed/tee-front.png differ diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts new file mode 100644 index 0000000..a387611 --- /dev/null +++ b/src/app/account/actions.ts @@ -0,0 +1,136 @@ +'use server'; + +import { randomBytes } from 'crypto'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; +import { createSession, clearSession, hashPassword, verifyPassword } from '@/lib/auth'; +import { sendPasswordResetEmail } from '@/lib/mail'; +import { upsertMailingListEntry } from '@/lib/mailingList'; +import { checkRateLimit, getClientIp } from '@/lib/rateLimit'; +import { verifyTurnstileToken } from '@/lib/turnstile'; + +const RESET_TOKEN_DURATION_MS = 60 * 60 * 1000; // 1 hour + +// Pulls in any past guest-checkout orders placed under this email so they show +// up in the customer's order history once they have an account. +async function linkGuestOrders(customerId: string, email: string) { + await prisma.order.updateMany({ + where: { email, customerId: null }, + data: { customerId }, + }); +} + +function safeNext(next: FormDataEntryValue | null) { + const path = String(next ?? ''); + return path.startsWith('/') && !path.startsWith('//') ? path : '/account'; +} + +export async function registerCustomer(formData: FormData) { + const name = String(formData.get('name') ?? '').trim(); + const email = String(formData.get('email') ?? '').trim().toLowerCase(); + const password = String(formData.get('password') ?? ''); + const confirmPassword = String(formData.get('confirmPassword') ?? ''); + const next = safeNext(formData.get('next')); + + if (!name || !email || !password) redirect('/account/register?error=missing'); + if (password !== confirmPassword) redirect('/account/register?error=mismatch'); + if (password.length < 8) redirect('/account/register?error=weak'); + + const ip = getClientIp(headers()); + const turnstileToken = String(formData.get('cf-turnstile-response') ?? ''); + const { success } = await verifyTurnstileToken(turnstileToken, ip); + if (!success) redirect('/account/register?error=captcha'); + + const existing = await prisma.customer.findUnique({ where: { email } }); + if (existing) redirect('/account/register?error=taken'); + + const passwordHash = await hashPassword(password); + const customer = await prisma.customer.create({ + data: { email, passwordHash, name }, + }); + + await upsertMailingListEntry({ email: customer.email, name: customer.name, source: 'CUSTOMER' }); + await linkGuestOrders(customer.id, customer.email); + await createSession(customer.id); + redirect(next); +} + +export async function loginCustomer(formData: FormData) { + const email = String(formData.get('email') ?? '').trim().toLowerCase(); + const password = String(formData.get('password') ?? ''); + const next = safeNext(formData.get('next')); + + const ip = getClientIp(headers()); + const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000); + if (!allowed) { + redirect(`/account/login?error=rate_limited&next=${encodeURIComponent(next)}`); + } + + const customer = email ? await prisma.customer.findUnique({ where: { email } }) : null; + const valid = customer ? await verifyPassword(password, customer.passwordHash) : false; + + if (!customer || !valid) { + redirect(`/account/login?error=invalid&next=${encodeURIComponent(next)}`); + } + + await linkGuestOrders(customer.id, customer.email); + await createSession(customer.id); + redirect(next); +} + +export async function logoutCustomer() { + clearSession(); + redirect('/'); +} + +export async function requestPasswordReset(formData: FormData) { + const email = String(formData.get('email') ?? '').trim().toLowerCase(); + const customer = email ? await prisma.customer.findUnique({ where: { email } }) : null; + + if (customer) { + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + RESET_TOKEN_DURATION_MS); + + await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id } }); + await prisma.passwordResetToken.create({ + data: { customerId: customer.id, token, expiresAt }, + }); + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'; + await sendPasswordResetEmail({ + to: customer.email, + name: customer.name, + link: `${siteUrl}/account/reset-password?token=${token}`, + }); + } + + // Same outcome whether or not the email matched an account, so we don't + // reveal which emails are registered. + redirect('/account/forgot-password?sent=1'); +} + +export async function resetPassword(formData: FormData) { + const token = String(formData.get('token') ?? ''); + const password = String(formData.get('password') ?? ''); + const confirmPassword = String(formData.get('confirmPassword') ?? ''); + + if (!token) redirect('/account/reset-password?error=invalid'); + if (password !== confirmPassword) redirect(`/account/reset-password?token=${token}&error=mismatch`); + if (password.length < 8) redirect(`/account/reset-password?token=${token}&error=weak`); + + const resetToken = await prisma.passwordResetToken.findUnique({ where: { token } }); + if (!resetToken || resetToken.expiresAt < new Date()) { + redirect('/account/reset-password?error=expired'); + } + + const passwordHash = await hashPassword(password); + await prisma.customer.update({ + where: { id: resetToken.customerId }, + data: { passwordHash }, + }); + await prisma.passwordResetToken.delete({ where: { token } }); + + await createSession(resetToken.customerId); + redirect('/account'); +} diff --git a/src/app/account/forgot-password/page.tsx b/src/app/account/forgot-password/page.tsx new file mode 100644 index 0000000..e72020b --- /dev/null +++ b/src/app/account/forgot-password/page.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link'; +import { requestPasswordReset } from '../actions'; + +export default function ForgotPasswordPage({ searchParams }: { searchParams: { sent?: string } }) { + const sent = searchParams.sent === '1'; + + return ( +
+

Reset your password

+ + {sent ? ( +

+ If that email has an account, we've sent a link to reset the password. It expires in 1 hour. +

+ ) : ( + <> +

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

+
+
+ + +
+ +
+ + )} + +

+ + Back to log in + +

+
+ ); +} diff --git a/src/app/account/login/page.tsx b/src/app/account/login/page.tsx new file mode 100644 index 0000000..c2eda81 --- /dev/null +++ b/src/app/account/login/page.tsx @@ -0,0 +1,61 @@ +import Link from 'next/link'; +import { loginCustomer } from '../actions'; + +const ERROR_MESSAGES: Record = { + invalid: 'Incorrect email or password.', + rate_limited: 'Too many attempts — please wait a few minutes and try again.', +}; + +export default function LoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) { + const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; + const next = searchParams.next ?? '/account'; + + return ( +
+

Log in

+

+ New here?{' '} + + Create an account + +

+ + {error && ( +

+ {error} +

+ )} + +
+ +
+ + +
+
+ + +
+ +
+ +

+ + Forgot your password? + +

+
+ ); +} diff --git a/src/app/account/orders/[id]/page.tsx b/src/app/account/orders/[id]/page.tsx new file mode 100644 index 0000000..718576f --- /dev/null +++ b/src/app/account/orders/[id]/page.tsx @@ -0,0 +1,95 @@ +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; +import { getCurrentCustomer } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +function formatPrice(cents: number) { + return `£${(cents / 100).toFixed(2)}`; +} + +function statusLabel(status: string) { + if (status === 'PAID') return 'Confirmed'; + if (status === 'FAILED') return 'Failed'; + return 'Processing'; +} + +function statusClasses(status: string) { + if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue'; + if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink'; + return 'bg-splash-orange/10 text-splash-orange'; +} + +export default async function AccountOrderDetailPage({ params }: { params: { id: string } }) { + const customer = await getCurrentCustomer(); + if (!customer) redirect('/account/login'); + + const order = await prisma.order.findUnique({ + where: { id: params.id }, + include: { items: true }, + }); + if (!order || order.customerId !== customer.id) notFound(); + + const shipping = order.shippingAddress ? JSON.parse(order.shippingAddress) : null; + + return ( +
+ + ← My account + +
+

Order

+ {statusLabel(order.status)} +
+ +
+
+

Placed

+

+ {new Date(order.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })} +

+
+ {shipping && ( +
+

Shipping address

+

+ {shipping.name} +
+ {[shipping.address?.line1, shipping.address?.line2].filter(Boolean).join(', ')} +
+ {[shipping.address?.city, shipping.address?.postal_code].filter(Boolean).join(', ')} +
+ {shipping.address?.country} +

+
+ )} +
+ +
+ {order.items.map((item) => ( +
+ {item.productName} +
+

{item.productName}

+

+ {item.color} + {item.size ? `, ${item.size}` : ''} · qty {item.quantity} +

+
+

{formatPrice(item.unitPrice * item.quantity)}

+
+ ))} +
+ +
+
+ Total + {formatPrice(order.total)} +
+
+
+ ); +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx new file mode 100644 index 0000000..c751173 --- /dev/null +++ b/src/app/account/page.tsx @@ -0,0 +1,80 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { getCurrentCustomer } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { logoutCustomer } from './actions'; + +function formatPrice(cents: number) { + return `£${(cents / 100).toFixed(2)}`; +} + +function statusLabel(status: string) { + if (status === 'PAID') return 'Confirmed'; + if (status === 'FAILED') return 'Failed'; + return 'Processing'; +} + +function statusClasses(status: string) { + if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue'; + if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink'; + return 'bg-splash-orange/10 text-splash-orange'; +} + +export default async function AccountPage() { + const customer = await getCurrentCustomer(); + if (!customer) redirect('/account/login'); + + const orders = await prisma.order.findMany({ + where: { customerId: customer.id }, + orderBy: { createdAt: 'desc' }, + include: { items: true }, + }); + + return ( +
+
+
+

My account

+

+ {customer.name ? `${customer.name} · ` : ''} + {customer.email} +

+
+
+ +
+
+ +

Order history

+ {orders.length === 0 ? ( +

No orders yet.

+ ) : ( +
+ {orders.map((order) => ( + +
+

+ {new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '} + {order.items.length} item{order.items.length === 1 ? '' : 's'} +

+ + {statusLabel(order.status)} + +
+ {formatPrice(order.total)} + + ))} +
+ )} +
+ ); +} diff --git a/src/app/account/register/page.tsx b/src/app/account/register/page.tsx new file mode 100644 index 0000000..b5410ac --- /dev/null +++ b/src/app/account/register/page.tsx @@ -0,0 +1,91 @@ +import Link from 'next/link'; +import { registerCustomer } from '../actions'; +import Turnstile from '@/components/Turnstile'; + +const ERROR_MESSAGES: Record = { + missing: 'Name, email, and password are required.', + mismatch: "Passwords don't match.", + weak: 'Password must be at least 8 characters.', + taken: 'An account with that email already exists.', + captcha: 'CAPTCHA verification failed — please try again.', +}; + +export default function RegisterPage({ searchParams }: { searchParams: { error?: string; next?: string } }) { + const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; + const next = searchParams.next ?? '/account'; + + return ( +
+

Create an account

+

+ Already have one?{' '} + + Log in + +

+ + {error && ( +

+ {error} +

+ )} + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +

+ By creating an account you agree to our{' '} + + Privacy Policy + + . +

+ +
+ ); +} diff --git a/src/app/account/reset-password/page.tsx b/src/app/account/reset-password/page.tsx new file mode 100644 index 0000000..90e84eb --- /dev/null +++ b/src/app/account/reset-password/page.tsx @@ -0,0 +1,75 @@ +import Link from 'next/link'; +import { resetPassword } from '../actions'; + +const ERROR_MESSAGES: Record = { + invalid: 'This reset link is invalid.', + expired: 'This reset link has expired — request a new one.', + mismatch: "Passwords don't match.", + weak: 'Password must be at least 8 characters.', +}; + +export default function ResetPasswordPage({ searchParams }: { searchParams: { token?: string; error?: string } }) { + const token = searchParams.token ?? ''; + const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; + + if (!token) { + return ( +
+

Reset your password

+

+ This reset link is invalid or incomplete. +

+

+ + Request a new link + +

+
+ ); + } + + return ( +
+

Choose a new password

+ + {error && ( +

+ {error} +

+ )} + +
+ +
+ + +
+
+ + +
+ +
+
+ ); +} diff --git a/src/app/actions.ts b/src/app/actions.ts new file mode 100644 index 0000000..2924ac3 --- /dev/null +++ b/src/app/actions.ts @@ -0,0 +1,32 @@ +'use server'; + +import { headers } from 'next/headers'; +import { prisma } from '@/lib/prisma'; +import { upsertMailingListEntry } from '@/lib/mailingList'; +import { verifyTurnstileToken } from '@/lib/turnstile'; +import { getClientIp } from '@/lib/rateLimit'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export async function subscribeToNewsletter(email: string, turnstileToken: string) { + const trimmed = email.trim().toLowerCase(); + if (!EMAIL_RE.test(trimmed)) { + return { ok: false, message: 'Enter a valid email address.' }; + } + + const ip = getClientIp(headers()); + const { success } = await verifyTurnstileToken(turnstileToken, ip); + if (!success) { + return { ok: false, message: 'CAPTCHA verification failed — please try again.' }; + } + + await prisma.newsletterSubscriber.upsert({ + where: { email: trimmed }, + create: { email: trimmed }, + update: {}, + }); + + await upsertMailingListEntry({ email: trimmed, source: 'NEWSLETTER' }); + + return { ok: true, message: "Thanks — you're on the list." }; +} diff --git a/src/app/admin/accounts/actions.ts b/src/app/admin/accounts/actions.ts new file mode 100644 index 0000000..86dd541 --- /dev/null +++ b/src/app/admin/accounts/actions.ts @@ -0,0 +1,91 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; +import { applyStockMovement } from '@/lib/stock'; + +export type ManualSaleItemInput = { + productId: string | null; // null = free-text one-off + description: string; + color: string | null; + size: string | null; + quantity: number; + unitPrice: number; // pence +}; + +export async function createManualSale(formData: FormData) { + const soldAtInput = String(formData.get('soldAt') ?? '').trim(); + const buyerName = String(formData.get('buyerName') ?? '').trim(); + const buyerEmail = String(formData.get('buyerEmail') ?? '').trim().toLowerCase(); + const paymentMethod = String(formData.get('paymentMethod') ?? 'CASH'); + const notes = String(formData.get('notes') ?? '').trim(); + const itemsJson = String(formData.get('items') ?? '[]'); + + if (!['CASH', 'BANK_TRANSFER'].includes(paymentMethod)) { + throw new Error('Unknown payment method.'); + } + + let items: ManualSaleItemInput[]; + try { + items = JSON.parse(itemsJson); + } catch { + throw new Error('Could not read the sale items.'); + } + items = items.filter((i) => i.description.trim() && i.quantity > 0); + if (items.length === 0) { + throw new Error('A sale needs at least one item.'); + } + for (const item of items) { + if (!Number.isInteger(item.quantity) || item.quantity < 1 || item.quantity > 10000) { + throw new Error('Quantities must be whole numbers.'); + } + if (!Number.isInteger(item.unitPrice) || item.unitPrice < 0) { + throw new Error('Prices cannot be negative.'); + } + } + + // Total always recomputed here — never trusted from the client. + const total = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0); + + // Auto-link to an existing customer account when the email matches, so this + // sale shows up in their combined purchase history later. + const customer = buyerEmail ? await prisma.customer.findUnique({ where: { email: buyerEmail } }) : null; + + await prisma.manualSale.create({ + data: { + soldAt: soldAtInput ? new Date(soldAtInput) : new Date(), + buyerName: buyerName || null, + buyerEmail: buyerEmail || null, + customerId: customer?.id ?? null, + paymentMethod, + total, + notes: notes || null, + items: { + create: items.map((i) => ({ + productId: i.productId, + description: i.description.trim(), + color: i.color, + size: i.size, + quantity: i.quantity, + unitPrice: i.unitPrice, + })), + }, + }, + }); + + // Product lines reduce stock; free-text lines carry none. + await applyStockMovement(items, -1); + + redirect('/admin/accounts'); +} + +export async function deleteManualSale(formData: FormData) { + const id = String(formData.get('id') ?? ''); + if (!id) return; + const sale = await prisma.manualSale.findUnique({ where: { id }, include: { items: true } }); + if (!sale) return; + await prisma.manualSale.delete({ where: { id } }); + // Deleting a sale puts its stock back — the sale never happened. + await applyStockMovement(sale.items, 1); + redirect('/admin/accounts'); +} diff --git a/src/app/admin/accounts/new/page.tsx b/src/app/admin/accounts/new/page.tsx new file mode 100644 index 0000000..babe326 --- /dev/null +++ b/src/app/admin/accounts/new/page.tsx @@ -0,0 +1,28 @@ +import { prisma } from '@/lib/prisma'; +import AdminNav from '@/components/AdminNav'; +import ManualSaleForm from '@/components/ManualSaleForm'; + +export default async function NewManualSalePage() { + const rows = await prisma.product.findMany({ orderBy: { name: 'asc' } }); + const products = rows.map((p) => ({ + id: p.id, + name: p.name, + basePrice: p.basePrice, + colors: JSON.parse(p.colors) as string[], + sizes: p.sizes ? (JSON.parse(p.sizes) as string[]) : [], + })); + + return ( +
+ +

Record a sale

+

+ For anything sold outside the website — cash at a fair, a bank transfer arranged over + Facebook. It lands in the same ledger as website orders. If the buyer's email matches a + customer account, the sale is linked to them automatically. +

+ + +
+ ); +} diff --git a/src/app/admin/accounts/page.tsx b/src/app/admin/accounts/page.tsx new file mode 100644 index 0000000..6711dcd --- /dev/null +++ b/src/app/admin/accounts/page.tsx @@ -0,0 +1,223 @@ +import Link from 'next/link'; +import { prisma } from '@/lib/prisma'; +import AdminNav from '@/components/AdminNav'; +import AccountsNav from '@/components/AccountsNav'; +import Price from '@/components/Price'; +import { deleteManualSale } from './actions'; + +const PAYMENT_LABELS: Record = { + CASH: 'Cash', + BANK_TRANSFER: 'Bank transfer', +}; + +// One row of the merged ledger — a website order and a manual sale flattened +// to the same shape so they can be sorted and totalled together. +type LedgerEntry = { + id: string; + kind: 'WEB' | 'MANUAL'; + date: Date; + who: string; + what: string; + method: string; + total: number; + href: string | null; // detail link (web orders only for now) +}; + +export default async function AccountsPage({ + searchParams, +}: { + searchParams: { type?: string; from?: string; to?: string }; +}) { + const type = searchParams.type === 'web' || searchParams.type === 'manual' ? searchParams.type : 'all'; + const from = searchParams.from ? new Date(searchParams.from) : null; + // Include the whole "to" day, not just its midnight. + const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null; + + const [orders, manualSales] = await Promise.all([ + type === 'manual' + ? [] + : prisma.order.findMany({ + where: { + status: 'PAID', + ...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}), + }, + include: { items: true, customer: true }, + }), + type === 'web' + ? [] + : prisma.manualSale.findMany({ + where: from || to ? { soldAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}, + include: { items: true }, + }), + ]); + + const entries: LedgerEntry[] = [ + ...orders.map((o) => ({ + id: o.id, + kind: 'WEB' as const, + date: o.createdAt, + who: o.customer?.name ?? o.email ?? 'Guest', + what: o.items.map((i) => `${i.quantity}× ${i.productName}`).join(', '), + method: 'Card (website)', + total: o.total, + href: `/admin/orders`, + })), + ...manualSales.map((s) => ({ + id: s.id, + kind: 'MANUAL' as const, + date: s.soldAt, + who: s.buyerName || s.buyerEmail || 'Unnamed buyer', + what: s.items.map((i) => `${i.quantity}× ${i.description}`).join(', '), + method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod, + total: s.total, + href: null, + })), + ].sort((a, b) => b.date.getTime() - a.date.getTime()); + + const totalAll = entries.reduce((sum, e) => sum + e.total, 0); + const totalWeb = entries.filter((e) => e.kind === 'WEB').reduce((sum, e) => sum + e.total, 0); + const totalManual = totalAll - totalWeb; + + const filterHref = (t: string) => { + const params = new URLSearchParams(); + if (t !== 'all') params.set('type', t); + if (searchParams.from) params.set('from', searchParams.from); + if (searchParams.to) params.set('to', searchParams.to); + const qs = params.toString(); + return `/admin/accounts${qs ? `?${qs}` : ''}`; + }; + + return ( +
+ +
+

Accounts

+ + + Record a sale + +
+ +

+ Every sale in one place — paid website orders plus anything you record by hand (cash, bank + transfer). Website orders appear here automatically once paid. +

+ + {/* Totals for whatever the current filter shows */} +
+
+

Total

+

+ +

+
+
+

Website

+

+ +

+
+
+

Manual

+

+ +

+
+
+ + {/* Filters */} +
+
+ {( + [ + ['all', 'All'], + ['web', 'Website'], + ['manual', 'Manual'], + ] as const + ).map(([value, label]) => ( + + {label} + + ))} +
+
+ {type !== 'all' && } + + + + {(searchParams.from || searchParams.to) && ( + + Clear dates + + )} +
+
+ + {/* Ledger */} + {entries.length === 0 ? ( +

No sales in this range yet.

+ ) : ( +
+ {entries.map((e) => ( +
+ + {e.kind === 'WEB' ? 'Website' : 'Manual'} + + + {e.date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })} + +
+

{e.who}

+

{e.what}

+
+ {e.method} + + + + {e.kind === 'MANUAL' ? ( +
+ + +
+ ) : ( + + View + + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/accounts/purchases/[id]/page.tsx b/src/app/admin/accounts/purchases/[id]/page.tsx new file mode 100644 index 0000000..1a39e18 --- /dev/null +++ b/src/app/admin/accounts/purchases/[id]/page.tsx @@ -0,0 +1,80 @@ +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; +import AdminNav from '@/components/AdminNav'; +import Price from '@/components/Price'; + +export default async function PurchaseDetailPage({ params }: { params: { id: string } }) { + const purchase = await prisma.purchase.findUnique({ + where: { id: params.id }, + include: { items: { include: { product: true } } }, + }); + if (!purchase) notFound(); + + const isPdf = purchase.invoiceDataUrl?.startsWith('data:application/pdf'); + + return ( +
+ +
+

{purchase.supplierName}

+ + {purchase.purchasedAt.toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })} + +
+ +
+ {purchase.items.map((item) => ( +
+
+

{item.description}

+

+ {item.productId + ? [item.color, item.size].filter(Boolean).join(' · ') || 'Stock item' + : 'Material / other — not stock-counted'} +

+
+ + {item.quantity} × + + + + +
+ ))} +
+ +

+ Total +

+ + {purchase.notes &&
{purchase.notes}
} + + {purchase.invoiceDataUrl && ( +
+

Invoice{purchase.invoiceFileName ? ` — ${purchase.invoiceFileName}` : ''}

+ {isPdf ? ( +