From f4a49fe9bcf56774aa29499a5fe0821efa6986d3 Mon Sep 17 00:00:00 2001 From: Greg Kilmer Date: Thu, 6 Aug 2026 12:13:48 -0400 Subject: [PATCH] Store base --- .env.example | 7 + ...ss_cfbaea37-9611-497f-804c-b438e5e659b3.md | 92 ++++++ docs/STORE.md | 129 ++++++++ public/images/deckboxes-example-1.svg | 27 ++ public/images/deckboxes-example-2.svg | 25 ++ public/images/proxies-example-1.svg | 25 ++ public/images/proxies-example-2.svg | 22 ++ src/components/nav/MobileMenu.tsx | 7 + src/components/store/Carousel.tsx | 240 ++++++++++++++ src/components/store/OrderForm.tsx | 304 ++++++++++++++++++ src/components/store/PricingTable.astro | 71 ++++ src/components/store/ProductCard.astro | 59 ++++ src/components/store/ProductTile.astro | 65 ++++ src/components/store/RequirementList.astro | 57 ++++ src/components/store/SlotMeter.astro | 66 ++++ src/components/store/StoreNav.astro | 137 ++++++++ src/data/store-availability.json | 10 + src/data/store-availability.ts | 39 +++ src/data/store-products.ts | 216 +++++++++++++ src/layouts/BaseLayout.astro | 7 +- src/layouts/StoreLayout.astro | 40 +++ src/lib/env.ts | 6 + src/pages/api/order.ts | 238 ++++++++++++++ src/pages/store/cards.astro | 126 ++++++++ src/pages/store/deck-boxes.astro | 109 +++++++ src/pages/store/index.astro | 206 ++++++++++++ src/pages/store/order.astro | 140 ++++++++ tsconfig.json | 3 +- 28 files changed, 2471 insertions(+), 2 deletions(-) create mode 100644 .zcode/plans/plan-sess_cfbaea37-9611-497f-804c-b438e5e659b3.md create mode 100644 docs/STORE.md create mode 100644 public/images/deckboxes-example-1.svg create mode 100644 public/images/deckboxes-example-2.svg create mode 100644 public/images/proxies-example-1.svg create mode 100644 public/images/proxies-example-2.svg create mode 100644 src/components/store/Carousel.tsx create mode 100644 src/components/store/OrderForm.tsx create mode 100644 src/components/store/PricingTable.astro create mode 100644 src/components/store/ProductCard.astro create mode 100644 src/components/store/ProductTile.astro create mode 100644 src/components/store/RequirementList.astro create mode 100644 src/components/store/SlotMeter.astro create mode 100644 src/components/store/StoreNav.astro create mode 100644 src/data/store-availability.json create mode 100644 src/data/store-availability.ts create mode 100644 src/data/store-products.ts create mode 100644 src/layouts/StoreLayout.astro create mode 100644 src/pages/api/order.ts create mode 100644 src/pages/store/cards.astro create mode 100644 src/pages/store/deck-boxes.astro create mode 100644 src/pages/store/index.astro create mode 100644 src/pages/store/order.astro diff --git a/.env.example b/.env.example index e84cc33..848721b 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,13 @@ SMTP_ALLOW_SELFSIGNED= # Optional webhook fallback (JSON POST). Only used if SMTP_HOST is unset. # CONTACT_WEBHOOK_URL= +# --- Store order form -> /api/order ----------------------------------------- +# Reuses the SMTP/relay settings above. ORDER_TO is the inbox that receives +# order requests; if blank it falls back to CONTACT_TO. No payment is taken; +# the form only collects an order request that is emailed to you. The order +# form is hard-gated by src/data/store-availability.json (ordersOpen). +ORDER_TO=orders@gregkilmer.com + # Honeypot / basic abuse control. Maximum submissions per IP per window. CONTACT_RATE_LIMIT=5 CONTACT_RATE_WINDOW_MIN=15 diff --git a/.zcode/plans/plan-sess_cfbaea37-9611-497f-804c-b438e5e659b3.md b/.zcode/plans/plan-sess_cfbaea37-9611-497f-804c-b438e5e659b3.md new file mode 100644 index 0000000..c54f190 --- /dev/null +++ b/.zcode/plans/plan-sess_cfbaea37-9611-497f-804c-b438e5e659b3.md @@ -0,0 +1,92 @@ +# Store (store.gregkilmer.com) — MTG Proxy Cards & 3D-Printed Deck Boxes / Counters + +## Summary +Add a self-contained **store** section to this repo that renders a commerce-style storefront for your proxy-card printing and 3D-printing services, with a gated email-submission order form. It lives at `/store` (and is reachable via `store.gregkilmer.com`), reuses the existing mystic night-sky aesthetic, and shares **no nav links** with the main site. A single JSON file you edit controls order-open/closed, per-category slot counts, and turnaround messaging. + +**Key constraint honored:** no payment is taken; the order form is **hard-locked** until you flip a flag in the JSON. + +--- + +## Architecture decision (defaulting, since the question went unanswered) +- **Same app, `/store` route set, with a store-only nav and zero cross-links from the main site.** Store pages are reachable only by direct URL or the `store.gregkilmer.com` subdomain (the subdomain routing is a reverse-proxy concern, not code). This reuses BaseLayout, the theme system, Icon, Reveal, SectionHeader, the contact/email pipeline, and Turnstile — no new dependencies. +- **Order lock = hard gate.** When `ordersOpen` is false the order page shows a clear "orders currently closed" notice, links to your email, and **renders no form at all**. When true, the full order form appears. +- **Capacity JSON drives:** overall store open/closed flag, open-slot counts for cards and for 3D-print jobs (shown as "X of Y open"), an estimated turnaround string, and a last-updated timestamp. + +--- + +## Files to create + +### 1. Store data + availability (the "simple JSON file you edit") +- **`src/data/store-availability.json`** — the single file you edit to control the storefront. Schema: + ```json + { + "ordersOpen": false, // master gate — false = form hidden + "lastUpdated": "2026-08-04", + "turnaround": "Estimated turnaround: ~2 weeks once a list is confirmed.", + "maxNote": "I take a limited number of orders at a time so each gets full attention.", + "slots": { + "cards": { "open": 0, "total": 5 }, + "deckBoxes": { "open": 0, "total": 4 } + } + } + ``` +- **`src/data/store-products.ts`** — typed product/pricing data (cards, deck boxes, finishes, sizing), authored in TS for type-safety and kept here (not JSON) so it's version-controlled with the components that render it. Pricing tables and product specs read from here. + +### 2. Store-only layout + nav +- **`src/layouts/StoreLayout.astro`** — wraps `BaseLayout` with `surface="interest"` (warm candlelit tone, clearly distinct from the dark professional wing but still on-brand). Passes a `noindex` option (store can be noindexed until you're ready to promote). Renders a **store-specific top nav** (`/store`, `/store/cards`, `/store/deck-boxes`, `/store/order`) with a store wordmark and a small "store.gregkilmer.com" label, and a store footer variant that points to your email — **no links back to the main site**, per your request. +- **`src/components/store/StoreNav.astro`** — the store nav bar (mirrors `NavBar.astro`'s scroll-blur treatment and StarBurst mark, but store items only). + +### 3. Reusable store UI components +- **`src/components/store/ProductCard.astro`** — image + spec list (size, thickness, finish, white back, etc.) for a product. +- **`src/components/store/PricingTable.astro`** — generic pricing-by-quantity table (takes rows of `{ quantity, price, note? }`). +- **`src/components/store/SlotMeter.astro`** — visual "X of Y slots open" meter with open/closed pill. +- **`src/components/store/RequirementList.astro`** — the rules/requirements list with mdi check/alert icons. + +### 4. Pages +- **`src/pages/store/index.astro`** — Storefront home: hero (uses existing `/images/proxies.jpg` / `/images/deckboxes.jpg`), intro, **clear disclaimer block** (not affiliated with WotC/any game company; these are clearly-marked proxies, not counterfeits; will not print offensive content or custom art), capacity/availability summary from the JSON, links to the two product lines + order page. +- **`src/pages/store/cards.astro`** — Proxy MTG cards product line: example images, spec details (size = standard MTG 63×88mm, cardstock thickness/finish options, white back, etc.), pricing table for sets (e.g. 1–9 / 10–39 / 40–99 / 100+ tiers), double-sided-counts-as-two rule callout. +- **`src/pages/store/deck-boxes.astro`** — 3D-printed deck box & counter product line: example images (`/images/deckboxes.jpg`), sizing/finish/material details, pricing table for prints. +- **`src/pages/store/order.astro`** — Order page. Reads `store-availability.json`; when `ordersOpen === false`, renders the **closed notice + email link, no form**. When open, renders the order form island and shows the slot meters + requirements inline. + +### 5. Order form + API +- **`src/components/store/OrderForm.tsx`** — React island modeled on the existing `ContactForm.tsx` (same idle/submitting/success/error states, honeypot, optional Turnstile, aria-live, same input styling). Fields: name, email, order type (cards / deck-box print / both), card list / deck-box spec (textarea), quantity/finish selectors, notes, honeypot, Turnstile token. **Posts to `/api/order`.** +- **`src/pages/api/order.ts`** — new POST endpoint cloned from `/api/contact.ts`'s proven pipeline: Zod validation, per-IP rate limit, Turnstile verification (reusing `@lib/env`), SMTP-or-webhook delivery to your inbox with a subject like `New store order — ${type}`. **Server-side hard gate:** even if a client POSTs while `ordersOpen` is false, the endpoint reads the JSON and returns 403 "Orders are currently closed." This makes the lock enforced both in UI and on the server. + +### 6. Env additions +- Append to **`.env.example`** (and document in the new docs file): an optional `ORDER_TO` inbox (defaults to `CONTACT_TO`), and reuse existing `TURNSTILE_*`, `SMTP_*`, and rate-limit vars — **no new dependencies**. Order emails reuse the same `deliver()` pattern. + +### 7. Docs +- **`docs/STORE.md`** — how to edit `store-availability.json` to open/close orders and update slots/turnaround, how the hard gate works (UI + API), how to point `store.gregkilmer.com` at the same server via your reverse proxy, and the "no cross-links" rule. + +--- + +## Files modified +- **`src/lib/env.ts`** — add `orderTo()` helper (falls back to `contactTo()`). +- **`.env.example`** — add `ORDER_TO=` with a comment. +- **`src/middleware.ts`** — no change needed; CSP already allows `'self'` form-action, Turnstile origins, and same-origin connect. (I'll add a comment noting `/api/order` reuses the existing policy.) +- No change to the main site nav (`site.ts`), Footer, or any main-site page — **zero cross-links**, as requested. + +--- + +## How each of your requirements is met +| Requirement | Where | +|---|---| +| Order form submits an email | `OrderForm.tsx` → `/api/order` → SMTP/webhook (cloned from working `/api/contact`) | +| Example images + product details | `cards.astro` / `deck_boxes.astro` + `ProductCard.astro` (size, thickness, finishes, white back, etc.) | +| Pricing tables for card sets & deck-box prints | `PricingTable.astro` fed by `store-products.ts` | +| Hosted at store.gregkilmer.com, no clear link between sites | `/store` route set, store-only nav, no main-site links; subdomain is a reverse-proxy mapping to the same server | +| No payment taken | Form collects a *request* only; no payment fields, no checkout | +| Order form locked until you choose to accept | Hard gate: `ordersOpen` in JSON hides the form (UI) **and** 403s the API (server) | +| Number of card + 3D-print slots, updatable from a simple JSON | `slots.cards` / `slots.deckBoxes` in `store-availability.json`, rendered by `SlotMeter.astro` | +| Clear "I can only take so many at a time" | `maxNote` + slot meters prominently on storefront and order page | +| Stated requirements (card list final, double-sided = 2 slots, payment is final, not affiliated with WotC, no counterfeits — clearly proxies, no offensive/custom art, etc.) | `RequirementList.astro` on storefront + repeated on the order page; order form requires an explicit acknowledgment checkbox | + +--- + +## Defaults I'll use (flag any you want changed) +- **Disclaimer copy** drafted to match your wording (not affiliated with WotC / any game company; clearly-marked proxies, never counterfeits; no offensive or unauthorized custom art; card list is final once received; double-sided = two cards/two slots; all sales final). You can tweak exact phrasing after. +- **Pricing & specs** are placeholder structure with clearly-marked `[Greg to fill]` values (you'll swap in your real tier prices, cardstock GSM, finish names, deck-box dimensions). I won't invent real prices. +- **Slot counts** default to `{ cards: 0/5, deckBoxes: 0/4 }` and `ordersOpen: false`, so the site launches visibly "closed." +- **`noindex`** on by default for store pages until you say otherwise. + +I will not commit or push anything. Ready to implement on approval. \ No newline at end of file diff --git a/docs/STORE.md b/docs/STORE.md new file mode 100644 index 0000000..276772f --- /dev/null +++ b/docs/STORE.md @@ -0,0 +1,129 @@ +# Store (`store.gregkilmer.com`) + +A small storefront for Greg's **proxy MTG card** and **3D-printed deck box / +counter** services. It lives at `/store` inside the same app, reuses the main +site's aesthetic, and is reachable directly or via the `store.gregkilmer.com` +subdomain. It takes **no payment** — the order form only emails a request — and +the form is **hard-locked** until you choose to accept orders. + +## Pages + +| Route | What it is | +| ------------------ | ------------------------------------------------ | +| `/store` | Storefront: intro, products, live slots, policies | +| `/store/cards` | Proxy MTG cards specs + pricing | +| `/store/deck-boxes`| 3D-printed deck boxes & counters specs + pricing | +| `/store/order` | Order request form (gated) or "closed" notice | + +The store uses its own nav (`StoreNav.astro`) and has **no links back to the +main site**. The two sites are intentionally not connected from the UI. + +## Opening and closing orders + +Everything customer-facing about availability is driven by **one file**: + +``` +src/data/store-availability.json +``` + +```json +{ + "ordersOpen": false, + "lastUpdated": "2026-08-04", + "turnaround": "Estimated turnaround: ~2 weeks once a list is confirmed.", + "maxNote": "I take a limited number of orders at a time...", + "slots": { + "cards": { "open": 0, "total": 5 }, + "deckBoxes": { "open": 0, "total": 4 } + } +} +``` + +- **`ordersOpen`** is the master switch. + - `false` → the order page shows a "closed" notice and renders **no form**. + - `true` → the order form appears. +- **`slots.cards` / `slots.deckBoxes`** drive the "X of Y open" meters on the + storefront and order page. Set `open` to 0 to show a category as Closed. +- **`turnaround`**, **`maxNote`**, and **`lastUpdated`** are shown verbatim. + +Edit the file, commit, and redeploy. That's it. + +### The lock is enforced in two places + +1. **UI**: `/store/order` reads the file at render and hides the form when + `ordersOpen` is false. +2. **Server**: `/api/order` reads the file again and returns **403** if + `ordersOpen` is false — *before* doing any validation or delivery. So even a + direct POST can't bypass the lock. + +## Editing products, specs, and pricing + +Those live in version-controlled TypeScript (not the availability JSON): + +``` +src/data/store-products.ts +``` + +Each product has an `icon`, `title`, `summary`, example `image`, a `specs` list +(size, thickness, finishes, white back, etc.), and `pricing` tiers. Search for +`[Greg to fill]` to find the placeholder prices and concrete specs to replace. + +The shared **requirements / policies** list is also in this file (`requirements`) +and is rendered on the storefront, the order page, and required as a checkbox on +the order form. + +## Order delivery (email) + +Order requests are emailed using the **same SMTP/webhook pipeline as the contact +form** (see `docs/DEPLOYMENT.md`). The only addition is the inbox: + +``` +ORDER_TO=orders@gregkilmer.com +``` + +If `ORDER_TO` is blank, it falls back to `CONTACT_TO`. Configure `SMTP_*` vars +as usual; if neither SMTP nor `CONTACT_WEBHOOK_URL` is set, `/api/order` returns +503 (delivery not configured) so it's visibly a config gap. + +Rate limiting reuses `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_MIN` unless you +set `ORDER_RATE_LIMIT` / `ORDER_RATE_WINDOW_MIN` to give orders a separate budget. + +Bot protection reuses the Cloudflare **Turnstile** keys (`TURNSTILE_SITE_KEY` / +`TURNSTILE_SECRET_KEY`). Leave both blank to skip Turnstile (e.g. local dev). + +## Pointing `store.gregkilmer.com` at the store + +The store is part of the same app — no separate server. The subdomain is purely +a reverse-proxy concern (Cosmos / Traefik / Nginx Proxy Manager): + +1. Create a DNS record for `store.gregkilmer.com`. +2. In your reverse proxy, point `store.gregkilmer.com` at the same container on + port 4321, with TLS. +3. (Optional) Add a redirect or rewrite so `store.gregkilmer.com/` maps to + `/store`. The store pages are reachable at `/store*` on the main domain too, + so a redirect isn't strictly required — visitors who know the path can reach + it either way. + +No code change is needed to enable the subdomain. + +## Search visibility + +Store pages are **`noindex` by default** (set in `StoreLayout.astro`). When +you're ready for the store to be indexed, change `noindex` to `false` there (or +override it per page via the `noindex` prop). + +## Files added / changed + +- `src/data/store-availability.json` — **the file you edit** +- `src/data/store-availability.ts` — typed reader (used by pages + API) +- `src/data/store-products.ts` — products, specs, pricing, requirements +- `src/components/store/` — `StoreNav`, `ProductCard`, `PricingTable`, + `SlotMeter`, `RequirementList`, `OrderForm.tsx` +- `src/layouts/StoreLayout.astro` — store wrapper (surface, store nav, noindex) +- `src/pages/store/` — `index`, `cards`, `deck-boxes`, `order` +- `src/pages/api/order.ts` — order endpoint with hard gate +- `src/layouts/BaseLayout.astro` — added optional `storeNav` prop (renders + `StoreNav` instead of the main `NavBar` on store pages) +- `src/components/nav/MobileMenu.tsx` — added store icon paths +- `src/lib/env.ts` — added `orderTo()` helper +- `.env.example` — added `ORDER_TO` diff --git a/public/images/deckboxes-example-1.svg b/public/images/deckboxes-example-1.svg new file mode 100644 index 0000000..ecb20cb --- /dev/null +++ b/public/images/deckboxes-example-1.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + 100+ + + PLACEHOLDER · SWAP IN /images/store-products.ts + diff --git a/public/images/deckboxes-example-2.svg b/public/images/deckboxes-example-2.svg new file mode 100644 index 0000000..668fa19 --- /dev/null +++ b/public/images/deckboxes-example-2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + LIFE + 40 + + + + + PLACEHOLDER · SWAP IN /images/store-products.ts + diff --git a/public/images/proxies-example-1.svg b/public/images/proxies-example-1.svg new file mode 100644 index 0000000..0b200aa --- /dev/null +++ b/public/images/proxies-example-1.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + PLACEHOLDER · SWAP IN /images/store-products.ts + diff --git a/public/images/proxies-example-2.svg b/public/images/proxies-example-2.svg new file mode 100644 index 0000000..4cc7c69 --- /dev/null +++ b/public/images/proxies-example-2.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + PROXY + NOT TOURNAMENT LEGAL + + PLACEHOLDER · SWAP IN /images/store-products.ts + diff --git a/src/components/nav/MobileMenu.tsx b/src/components/nav/MobileMenu.tsx index cfb5250..b976da7 100644 --- a/src/components/nav/MobileMenu.tsx +++ b/src/components/nav/MobileMenu.tsx @@ -38,6 +38,13 @@ const ICON_PATHS: Record = { 'M6 3h5v5H6V3zm7 0h5v5h-5V3zM3 11h5v5H3v-5zm7 0h5v5h-5v-5zm7 0h-5v5h5v-5z', email: 'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm8 7L4 6v1l8 5 8-5V6l-8 5z', + // Store nav icons (kept here so the store mobile menu is self-contained). + store: 'M4 7h16l-1 13H5L4 7zm5-3a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0 3 3', + 'cards-playing': 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm3 4v3m6 7v-3', + 'printer-3d-nozzle': + 'M7 2h10v4l3 3-4 2H8L4 9l3-3V2zm5 11v9', + 'clipboard-list': + 'M9 3h6v2H9V3zm-2 0v2H6a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-1V3M7 11h2m4 0h4M7 15h2m4 0h4', }; function Icon({ name, className }: { name: string; className?: string }) { diff --git a/src/components/store/Carousel.tsx b/src/components/store/Carousel.tsx new file mode 100644 index 0000000..b68bf0f --- /dev/null +++ b/src/components/store/Carousel.tsx @@ -0,0 +1,240 @@ +/** + * Carousel.tsx + * --------------------------------------------------------------------------- + * Server-driven image carousel for store product pages. The list of slides + * comes from the product's `images` array (defined in store-products.ts), so + * you manage what's shown purely from the data file. + * + * Features: + * - Swipe / drag via pointer events (works for touch + mouse). + * - A caption (label) rendered on each slide. + * - Prev/next buttons + dot indicators. + * - Keyboard accessible (buttons + left/right arrows when focused). + * - Respects prefers-reduced-motion (no slide animation, instant swap). + * + * The carousel is presentation-only on the image; each slide's alt text + * carries the meaning for screen readers, and the caption is rendered as + * visible text (not aria-hidden) so it's announced. + */ +import { useState, useRef, useCallback, type PointerEvent } from 'react'; + +export interface CarouselSlide { + src: string; + alt: string; + label: string; +} + +interface Props { + slides: CarouselSlide[]; + /** Optional aria-label for the carousel region. */ + label?: string; +} + +export default function Carousel({ slides, label = 'Product examples' }: Props) { + const [index, setIndex] = useState(0); + const [dragging, setDragging] = useState(false); + // Horizontal drag delta in px accumulated during the current gesture. + const dragDeltaRef = useRef(0); + const startXRef = useRef(0); + // Live offset applied to the track during a drag (px), for visual feedback. + const [liveOffset, setLiveOffset] = useState(0); + const trackRef = useRef(null); + + const count = slides.length; + + const goTo = useCallback( + (i: number) => { + if (count === 0) return; + const next = (i + count) % count; + setIndex(next); + }, + [count], + ); + + const next = useCallback(() => goTo(index + 1), [goTo, index]); + const prev = useCallback(() => goTo(index - 1), [goTo, index]); + + // --- Pointer (touch + mouse) drag handling -------------------------------- + // Threshold in px before a drag counts as a swipe. Below it, the gesture is + // treated as a click (ignored), so taps on the image don't advance. + const SWIPE_THRESHOLD = 40; + + const onPointerDown = (e: PointerEvent) => { + // Only react to primary button / touch. + if (e.button !== 0 && e.pointerType === 'mouse') return; + setDragging(true); + startXRef.current = e.clientX; + dragDeltaRef.current = 0; + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + }; + + const onPointerMove = (e: PointerEvent) => { + if (!dragging) return; + const delta = e.clientX - startXRef.current; + dragDeltaRef.current = delta; + setLiveOffset(delta); + }; + + const finishDrag = () => { + if (!dragging) return; + const delta = dragDeltaRef.current; + setDragging(false); + setLiveOffset(0); + if (delta <= -SWIPE_THRESHOLD) { + next(); + } else if (delta >= SWIPE_THRESHOLD) { + prev(); + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + prev(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + next(); + } + }; + + if (count === 0) return null; + + const slide = slides[index]!; + + return ( +
+
+ {/* Slides track. We translate by -index*100% and apply the live drag + offset for feedback. touch-action: pan-y lets vertical scroll pass + through while we capture horizontal swipes. */} +
+ {slides.map((s, i) => ( +
+ {s.alt} + {/* Caption gradient + label, always visible for the active slide. */} +
+ + {s.label} + +
+
+ ))} +
+ + {/* Prev / next controls. */} + {count > 1 && ( + <> + + + + )} + + {/* Slide counter (top-right). */} + + {index + 1} / {count} + +
+ + {/* Dot indicators. */} + {count > 1 && ( +
+ {slides.map((s, i) => ( +
+ )} + + {/* sr-only live region announcing the active slide's label. */} + + {slide.label} + +
+ ); +} diff --git a/src/components/store/OrderForm.tsx b/src/components/store/OrderForm.tsx new file mode 100644 index 0000000..b750b81 --- /dev/null +++ b/src/components/store/OrderForm.tsx @@ -0,0 +1,304 @@ +/** + * OrderForm.tsx + * --------------------------------------------------------------------------- + * Store order-request form island. Modeled on ContactForm.tsx: same + * idle/submitting/success/error states, honeypot, optional Turnstile, aria-live + * status, and identical input styling. Posts to /api/order. + * + * This collects an ORDER REQUEST only — no payment fields, no checkout. The + * submission is emailed to Greg. The server endpoint (/api/order) independently + * re-checks store-availability.json and 403s if orders are closed, so the gate + * can't be bypassed from the client. + * + * The form requires an explicit acknowledgment of the store policies (checkbox) + * before it can be submitted. + */ +import { useState, useRef, useEffect, type FormEvent } from 'react'; + +type Status = 'idle' | 'submitting' | 'success' | 'error'; +type OrderType = 'cards' | 'deckBoxes' | 'both'; + +interface FieldErrors { + name?: string; + email?: string; + details?: string; + quantity?: string; + agree?: string; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +declare global { + interface Window { + turnstile?: { + render: (el: string | HTMLElement, opts: Record) => string; + reset: (id?: string) => void; + remove: (id: string) => void; + }; + } +} + +interface Props { + /** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */ + turnstileSiteKey?: string; +} + +export default function OrderForm({ turnstileSiteKey }: Props) { + const [status, setStatus] = useState('idle'); + const [errors, setErrors] = useState({}); + const [errorMsg, setErrorMsg] = useState(''); + + const turnstileTokenRef = useRef(''); + const turnstileWidgetIdRef = useRef(null); + const turnstileContainerRef = useRef(null); + + // Same Turnstile bootstrap as ContactForm. + useEffect(() => { + if (!turnstileSiteKey) return; + if (window.turnstile) { + renderWidget(); + return; + } + let script = document.getElementById('cf-turnstile-script') as HTMLScriptElement | null; + if (!script) { + script = document.createElement('script'); + script.id = 'cf-turnstile-script'; + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + script.async = true; + script.defer = true; + document.head.appendChild(script); + } + script.addEventListener('load', renderWidget); + return () => { + script?.removeEventListener('load', renderWidget); + }; + }, [turnstileSiteKey]); + + function renderWidget() { + if (!turnstileSiteKey) return; + if (!window.turnstile || !turnstileContainerRef.current) return; + if (turnstileWidgetIdRef.current) return; + turnstileWidgetIdRef.current = window.turnstile.render(turnstileContainerRef.current, { + sitekey: turnstileSiteKey, + callback: (token: string) => { + turnstileTokenRef.current = token; + }, + 'expired-callback': () => { + turnstileTokenRef.current = ''; + }, + 'error-callback': () => { + turnstileTokenRef.current = ''; + }, + theme: 'dark', + }); + } + + interface OrderValues { + name: string; + email: string; + orderType: OrderType; + quantity: string; + details: string; + notes: string; + agree: boolean; + company: string; + turnstileToken: string; + } + + function validate(v: OrderValues): FieldErrors { + const e: FieldErrors = {}; + if (!v.name.trim()) e.name = 'Please enter your name.'; + if (!v.email.trim()) e.email = 'Please enter your email.'; + else if (!EMAIL_RE.test(v.email)) e.email = 'That email address looks off.'; + if (!v.details.trim()) e.details = 'Please describe your order (card list or deck-box spec).'; + else if (v.details.trim().length < 10) e.details = 'A little more detail, please (10+ characters).'; + if (!v.quantity.trim()) e.quantity = 'Please enter an approximate quantity.'; + if (!v.agree) e.agree = 'You must acknowledge the policies to continue.'; + return e; + } + + async function onSubmit(ev: FormEvent) { + ev.preventDefault(); + const form = ev.currentTarget; + const data = new FormData(form); + const values: OrderValues = { + name: String(data.get('name') ?? '').trim(), + email: String(data.get('email') ?? '').trim(), + orderType: (String(data.get('orderType') ?? 'cards') as OrderType), + quantity: String(data.get('quantity') ?? '').trim(), + details: String(data.get('details') ?? '').trim(), + notes: String(data.get('notes') ?? '').trim(), + agree: data.get('agree') === 'on', + company: String(data.get('company') ?? ''), + turnstileToken: turnstileTokenRef.current, + }; + + const found = validate(values); + if (turnstileSiteKey && !values.turnstileToken) { + found.agree = found.agree ?? 'Please complete the bot check and try again.'; + } + setErrors(found); + if (Object.keys(found).length > 0) return; + + setStatus('submitting'); + setErrorMsg(''); + try { + const res = await fetch('/api/order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(values), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + if (turnstileSiteKey && turnstileWidgetIdRef.current) { + window.turnstile?.reset(turnstileWidgetIdRef.current); + turnstileTokenRef.current = ''; + } + throw new Error(body.error || 'Something went wrong. Please try again.'); + } + setStatus('success'); + form.reset(); + if (turnstileSiteKey && turnstileWidgetIdRef.current) { + window.turnstile?.reset(turnstileWidgetIdRef.current); + turnstileTokenRef.current = ''; + } + } catch (err) { + setStatus('error'); + setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); + } + } + + const inputBase = + 'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]'; + const labelBase = 'mb-1.5 block font-sans text-sm font-medium'; + const fieldColor = { color: 'var(--color-text)' }; + const borderFor = (k: keyof FieldErrors) => (errors[k] ? 'var(--color-accent)' : 'var(--color-border)'); + + if (status === 'success') { + return ( +
+ +

+ Order request sent. +

+

+ Thanks! I'll review your request and reply by email. No payment is taken through the site. +

+ +
+ ); + } + + return ( +
+ {/* Honeypot */} + + +
+ + + {errors.name &&

{errors.name}

} +
+ +
+ + + {errors.email &&

{errors.email}

} +
+ +
+ + +
+ +
+ + + {errors.quantity &&

{errors.quantity}

} +
+ +
+ +