Store base

This commit is contained in:
Greg Kilmer
2026-08-06 12:13:48 -04:00
parent 51a7b9fee0
commit f4a49fe9bc
28 changed files with 2471 additions and 2 deletions
+7
View File
@@ -36,6 +36,13 @@ SMTP_ALLOW_SELFSIGNED=
# Optional webhook fallback (JSON POST). Only used if SMTP_HOST is unset. # Optional webhook fallback (JSON POST). Only used if SMTP_HOST is unset.
# CONTACT_WEBHOOK_URL= # 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. # Honeypot / basic abuse control. Maximum submissions per IP per window.
CONTACT_RATE_LIMIT=5 CONTACT_RATE_LIMIT=5
CONTACT_RATE_WINDOW_MIN=15 CONTACT_RATE_WINDOW_MIN=15
@@ -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.
+129
View File
@@ -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`
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-label="Placeholder image: deck box open showing card capacity">
<defs>
<linearGradient id="bg3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#170f24"/>
<stop offset="1" stop-color="#221630"/>
</linearGradient>
<radialGradient id="glow3" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#e8b54a" stop-opacity="0.16"/>
<stop offset="1" stop-color="#e8b54a" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="480" fill="url(#bg3)"/>
<rect width="640" height="480" fill="url(#glow3)"/>
<!-- open deck box, lid back, cards inside -->
<g transform="translate(150 150)">
<!-- base -->
<rect x="0" y="80" width="340" height="180" rx="14" fill="#221630" stroke="#3e2f4a" stroke-width="2"/>
<!-- lid (hinged back) -->
<rect x="0" y="0" width="340" height="90" rx="14" fill="#2c2050" stroke="#3e2f4a" stroke-width="2"/>
<!-- cards stack -->
<rect x="30" y="40" width="200" height="130" rx="8" fill="#f4f1e8" opacity="0.9"/>
<rect x="40" y="30" width="200" height="130" rx="8" fill="#e8b54a" opacity="0.35"/>
<rect x="50" y="20" width="200" height="130" rx="8" fill="#8b7bd8" opacity="0.35"/>
<text x="270" y="180" font-family="monospace" font-size="14" fill="#c4ab8e">100+</text>
</g>
<text x="320" y="450" text-anchor="middle" font-family="monospace" font-size="16" fill="#c4ab8e" letter-spacing="2">PLACEHOLDER &#183; SWAP IN /images/store-products.ts</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-label="Placeholder image: counter close-up with life totals">
<defs>
<linearGradient id="bg4" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#170f24"/>
<stop offset="1" stop-color="#221630"/>
</linearGradient>
<radialGradient id="glow4" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#6fb6ad" stop-opacity="0.16"/>
<stop offset="1" stop-color="#6fb6ad" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="480" fill="url(#bg4)"/>
<rect width="640" height="480" fill="url(#glow4)"/>
<!-- spinner-style counter -->
<g transform="translate(220 110)">
<circle cx="100" cy="130" r="110" fill="#221630" stroke="#3e2f4a" stroke-width="2"/>
<circle cx="100" cy="130" r="86" fill="none" stroke="#e8b54a" stroke-width="3" opacity="0.5"/>
<text x="100" y="120" text-anchor="middle" font-family="monospace" font-size="13" fill="#c4ab8e" letter-spacing="2">LIFE</text>
<text x="100" y="165" text-anchor="middle" font-family="sans-serif" font-size="46" font-weight="700" fill="#f4f1e8">40</text>
<!-- dial nubs -->
<circle cx="100" cy="24" r="6" fill="#8b7bd8"/>
<circle cx="100" cy="236" r="6" fill="#8b7bd8"/>
</g>
<text x="320" y="450" text-anchor="middle" font-family="monospace" font-size="16" fill="#c4ab8e" letter-spacing="2">PLACEHOLDER &#183; SWAP IN /images/store-products.ts</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-label="Placeholder image: close-up of card finish and cut">
<defs>
<linearGradient id="bg1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#170f24"/>
<stop offset="1" stop-color="#221630"/>
</linearGradient>
<radialGradient id="glow1" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#e8b54a" stop-opacity="0.18"/>
<stop offset="1" stop-color="#e8b54a" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="480" fill="url(#bg1)"/>
<rect width="640" height="480" fill="url(#glow1)"/>
<!-- stylized card -->
<g transform="translate(170 70) rotate(-6)">
<rect width="300" height="420" rx="18" fill="#0b1026" stroke="#3e2f4a" stroke-width="2"/>
<rect x="22" y="22" width="256" height="100" rx="8" fill="#221630"/>
<rect x="22" y="140" width="256" height="180" rx="8" fill="#2c2050" opacity="0.5"/>
<rect x="22" y="336" width="180" height="14" rx="4" fill="#8b7bd8" opacity="0.5"/>
<rect x="22" y="360" width="120" height="14" rx="4" fill="#8b7bd8" opacity="0.3"/>
<!-- sparkle -->
<path d="M150 60 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4 z" fill="#e8b54a" opacity="0.8"/>
</g>
<text x="320" y="450" text-anchor="middle" font-family="monospace" font-size="16" fill="#c4ab8e" letter-spacing="2">PLACEHOLDER &#183; SWAP IN /images/store-products.ts</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-label="Placeholder image: white card back showing the proxy marking">
<defs>
<linearGradient id="bg2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#170f24"/>
<stop offset="1" stop-color="#221630"/>
</linearGradient>
<radialGradient id="glow2" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#8b7bd8" stop-opacity="0.16"/>
<stop offset="1" stop-color="#8b7bd8" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="480" fill="url(#bg2)"/>
<rect width="640" height="480" fill="url(#glow2)"/>
<!-- white-backed card -->
<g transform="translate(170 70) rotate(5)">
<rect width="300" height="420" rx="18" fill="#f4f1e8" stroke="#3e2f4a" stroke-width="2"/>
<rect x="30" y="30" width="240" height="360" rx="10" fill="none" stroke="#d2c6a2" stroke-width="2" stroke-dasharray="6 6"/>
<text x="150" y="210" text-anchor="middle" font-family="sans-serif" font-size="34" font-weight="700" fill="#1b1730">PROXY</text>
<text x="150" y="248" text-anchor="middle" font-family="monospace" font-size="13" fill="#4a445e" letter-spacing="2">NOT TOURNAMENT LEGAL</text>
</g>
<text x="320" y="450" text-anchor="middle" font-family="monospace" font-size="16" fill="#c4ab8e" letter-spacing="2">PLACEHOLDER &#183; SWAP IN /images/store-products.ts</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+7
View File
@@ -38,6 +38,13 @@ const ICON_PATHS: Record<string, string> = {
'M6 3h5v5H6V3zm7 0h5v5h-5V3zM3 11h5v5H3v-5zm7 0h5v5h-5v-5zm7 0h-5v5h5v-5z', 'M6 3h5v5H6V3zm7 0h5v5h-5V3zM3 11h5v5H3v-5zm7 0h5v5h-5v-5zm7 0h-5v5h5v-5z',
email: 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', '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 }) { function Icon({ name, className }: { name: string; className?: string }) {
+240
View File
@@ -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<HTMLDivElement | null>(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<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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 (
<div
className="flex flex-col gap-3"
role="group"
aria-roledescription="carousel"
aria-label={label}
>
<div
className="relative overflow-hidden rounded-2xl border select-none"
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface-sunken)' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
onKeyDown={onKeyDown}
tabIndex={0}
>
{/* 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. */}
<div
ref={trackRef}
className="flex transition-transform duration-300 ease-out"
style={{
transform: `translateX(calc(${-index * 100}% + ${liveOffset}px))`,
transitionDuration: dragging ? '0ms' : undefined,
touchAction: 'pan-y',
}}
>
{slides.map((s, i) => (
<div
key={i}
className="relative w-full shrink-0"
aria-hidden={i !== index}
aria-roledescription="slide"
aria-label={`Slide ${i + 1} of ${count}`}
>
<img
src={s.src}
alt={s.alt}
draggable={false}
loading={i === 0 ? 'eager' : 'lazy'}
decoding="async"
className="aspect-[16/9] w-full object-cover"
style={{ pointerEvents: 'none' }}
/>
{/* Caption gradient + label, always visible for the active slide. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end p-4"
style={{ background: 'linear-gradient(to top, rgba(7,10,28,0.85), transparent)' }}
>
<span
className="font-sans text-sm font-semibold sm:text-base"
style={{ color: 'var(--color-cream)' }}
>
{s.label}
</span>
</div>
</div>
))}
</div>
{/* Prev / next controls. */}
{count > 1 && (
<>
<button
type="button"
onClick={prev}
aria-label="Previous slide"
className="absolute left-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
style={{
color: 'var(--color-cream)',
borderColor: 'color-mix(in srgb, var(--color-accent) 40%, transparent)',
background: 'rgba(7,10,28,0.5)',
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="Next slide"
className="absolute right-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
style={{
color: 'var(--color-cream)',
borderColor: 'color-mix(in srgb, var(--color-accent) 40%, transparent)',
background: 'rgba(7,10,28,0.5)',
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</>
)}
{/* Slide counter (top-right). */}
<span
className="pointer-events-none absolute right-3 top-3 rounded-full px-2.5 py-1 font-mono text-xs"
style={{ color: 'var(--color-cream)', background: 'rgba(7,10,28,0.6)' }}
>
{index + 1} / {count}
</span>
</div>
{/* Dot indicators. */}
{count > 1 && (
<div className="flex items-center justify-center gap-2">
{slides.map((s, i) => (
<button
key={i}
type="button"
onClick={() => goTo(i)}
aria-label={`Go to slide ${i + 1}`}
aria-current={i === index}
className="h-2 rounded-full transition-all"
style={{
width: i === index ? '1.75rem' : '0.5rem',
background:
i === index
? 'var(--color-accent)'
: 'color-mix(in srgb, var(--color-text-subtle) 50%, transparent)',
}}
/>
))}
</div>
)}
{/* sr-only live region announcing the active slide's label. */}
<span className="sr-only" aria-live="polite">
{slide.label}
</span>
</div>
);
}
+304
View File
@@ -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, unknown>) => 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<Status>('idle');
const [errors, setErrors] = useState<FieldErrors>({});
const [errorMsg, setErrorMsg] = useState<string>('');
const turnstileTokenRef = useRef<string>('');
const turnstileWidgetIdRef = useRef<string | null>(null);
const turnstileContainerRef = useRef<HTMLDivElement | null>(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<HTMLFormElement>) {
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 (
<div
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
>
<span
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
aria-hidden="true"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
</span>
<h3 className="font-sans text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Order request sent.
</h3>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Thanks! I'll review your request and reply by email. No payment is taken through the site.
</p>
<button type="button" onClick={() => setStatus('idle')} className="btn btn-secondary mt-2">
Submit another
</button>
</div>
);
}
return (
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
{/* Honeypot */}
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
<label htmlFor="company">Company (leave blank)</label>
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="flex flex-col">
<label htmlFor="name" className={labelBase} style={fieldColor}>Name</label>
<input id="name" name="name" type="text" autoComplete="name" className={inputBase} style={{ borderColor: borderFor('name'), ...fieldColor }} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'name-error' : undefined} />
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
</div>
<div className="flex flex-col">
<label htmlFor="email" className={labelBase} style={fieldColor}>Email</label>
<input id="email" name="email" type="email" autoComplete="email" className={inputBase} style={{ borderColor: borderFor('email'), ...fieldColor }} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'email-error' : undefined} />
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
</div>
<div className="flex flex-col">
<label htmlFor="orderType" className={labelBase} style={fieldColor}>Order type</label>
<select id="orderType" name="orderType" defaultValue="cards" className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor }}>
<option value="cards">Proxy MTG cards</option>
<option value="deckBoxes">3D-printed deck box / counters</option>
<option value="both">Both</option>
</select>
</div>
<div className="flex flex-col">
<label htmlFor="quantity" className={labelBase} style={fieldColor}>Approximate quantity</label>
<input id="quantity" name="quantity" type="text" placeholder="e.g. 75 cards, or 1 deck box + 4 counters" className={inputBase} style={{ borderColor: borderFor('quantity'), ...fieldColor }} aria-invalid={Boolean(errors.quantity)} aria-describedby={errors.quantity ? 'quantity-error' : undefined} />
{errors.quantity && <p id="quantity-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.quantity}</p>}
</div>
<div className="flex flex-col">
<label htmlFor="details" className={labelBase} style={fieldColor}>Card list / deck-box spec</label>
<textarea
id="details"
name="details"
rows={6}
className={inputBase}
style={{ borderColor: borderFor('details'), ...fieldColor, resize: 'vertical' }}
placeholder={'For cards: paste your full card list (one per line). Remember double-sided cards count as 2 slots.\nFor deck boxes: describe capacity, color, and any counters.'}
aria-invalid={Boolean(errors.details)}
aria-describedby={errors.details ? 'details-error' : undefined}
/>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
The card list is final once received — cards cannot be changed for that order.
</p>
{errors.details && <p id="details-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.details}</p>}
</div>
<div className="flex flex-col">
<label htmlFor="notes" className={labelBase} style={fieldColor}>Notes <span className="font-normal" style={{ color: 'var(--color-text-subtle)' }}>(optional)</span></label>
<textarea id="notes" name="notes" rows={3} className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor, resize: 'vertical' }} />
</div>
{/* Policy acknowledgment */}
<label className="flex items-start gap-3 text-sm" style={{ color: 'var(--color-text-muted)' }}>
<input
id="agree"
name="agree"
type="checkbox"
className="mt-1 h-4 w-4 shrink-0"
style={{ accentColor: 'var(--color-accent)' }}
/>
<span>
I understand these are clearly-marked proxies (not counterfeits), the card list is final
once received, double-sided cards count as 2 slots, and all sales are final. I am not
requesting custom or offensive art.
</span>
</label>
{errors.agree && <p className="-mt-2 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.agree}</p>}
{status === 'error' && (
<p className="rounded-lg p-3 text-sm" style={{ background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', color: 'var(--color-accent)' }}>
{errorMsg}
</p>
)}
{turnstileSiteKey && (
<div ref={turnstileContainerRef} className="min-h-[65px]" aria-label="Bot check" />
)}
<button type="submit" disabled={status === 'submitting'} className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60">
{status === 'submitting' ? 'Sending…' : 'Send order request'}
</button>
<p className="text-xs" style={{ color: 'var(--color-text-subtle)' }}>
No payment is taken through this site. Your request is emailed to Greg, who will reply to
confirm details and availability.
</p>
<p className="sr-only" role="status" aria-live="polite">
{status === 'submitting' ? 'Sending your order request.' : ''}
</p>
</form>
);
}
+71
View File
@@ -0,0 +1,71 @@
---
/**
* PricingTable.astro
* ---------------------------------------------------------------------------
* Generic quantity/pricing table. Takes a caption + a list of tiers
* ({ band, price, note? }) and renders a clean three-column table. Used for
* both the card-set tiers and the deck-box print tiers.
*/
import Icon from '@components/Icon.astro';
interface Tier {
band: string;
price: string;
note?: string;
}
interface Props {
/** mdi icon shown beside the caption. */
icon?: string;
caption: string;
tiers: Tier[];
/** Column header for the price column, e.g. "Price". */
priceHeader?: string;
/** Optional supporting line under the caption. */
subtitle?: string;
}
const { icon, caption, tiers, priceHeader = 'Price', subtitle } = Astro.props;
---
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
{icon && <Icon name={icon} class="h-5 w-5" style="color: var(--color-accent);" />}
{caption}
</span>
{subtitle && (
<span class="text-sm" style="color: var(--color-text-muted);">{subtitle}</span>
)}
</div>
<div class="overflow-hidden rounded-xl border" style="border-color: var(--color-border);">
<table class="w-full border-collapse text-left text-sm">
<thead>
<tr style="background: var(--color-surface-sunken);">
<th scope="col" class="px-4 py-3 font-sans text-xs font-semibold uppercase tracking-wider" style="color: var(--color-text-subtle);">
Quantity
</th>
<th scope="col" class="px-4 py-3 text-right font-sans text-xs font-semibold uppercase tracking-wider" style="color: var(--color-text-subtle);">
{priceHeader}
</th>
</tr>
</thead>
<tbody>
{tiers.map((tier, i) => (
<tr style={i > 0 ? 'border-top: 1px solid var(--color-border);' : undefined}>
<td class="px-4 py-3" style="color: var(--color-text);">
<span class="font-medium">{tier.band}</span>
{tier.note && (
<span class="ml-2 text-xs" style="color: var(--color-text-subtle);">({tier.note})</span>
)}
</td>
<td class="px-4 py-3 text-right font-mono font-semibold" style="color: var(--color-accent);">
{tier.price}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
+59
View File
@@ -0,0 +1,59 @@
---
/**
* ProductCard.astro
* ---------------------------------------------------------------------------
* Product specification block: a title + summary and a spec definition list
* (size, thickness, finishes, white back, etc.). Example IMAGES live in the
* product-page Carousel, not here, so this component is specs-only now.
*
* Used on the product detail pages (cards / deck-boxes). On the storefront we
* use the compact ProductTile instead (image + title + summary).
*/
import Icon from '@components/Icon.astro';
import type { Product } from '@data/store-products';
interface Props {
product: Product;
/** When true, show the full spec list (detail pages). When false, just
* title + summary. */
detailed?: boolean;
/** Optional link wrapped around the compact card (storefront tiles). */
href?: string;
}
const { product, detailed = false, href } = Astro.props;
const Wrapper = href ? 'a' : 'div';
---
<Wrapper
href={href}
class:list={[
'group flex flex-col gap-4 rounded-2xl border p-4',
href && 'card-interactive',
]}
style="border-color: var(--color-border); background: var(--color-surface-raised);"
>
<div class="flex flex-col gap-2">
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
<Icon name={product.icon} class="h-5 w-5" style="color: var(--color-accent);" />
{product.title}
</span>
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{product.summary}</p>
</div>
{detailed && (
<dl class="flex flex-col gap-0 rounded-xl border text-sm" style="border-color: var(--color-border);">
{product.specs.map((spec, i) => (
<div
class="flex flex-col gap-0.5 px-4 py-3 sm:flex-row sm:items-baseline sm:gap-4"
style={i > 0 ? 'border-top: 1px solid var(--color-border);' : undefined}
>
<dt class="font-sans text-xs font-semibold uppercase tracking-wider sm:w-40 sm:shrink-0" style="color: var(--color-text-subtle);">
{spec.label}
</dt>
<dd style="color: var(--color-text);">{spec.value}</dd>
</div>
))}
</dl>
)}
</Wrapper>
+65
View File
@@ -0,0 +1,65 @@
---
/**
* ProductTile.astro
* ---------------------------------------------------------------------------
* Compact storefront tile for a product line: the first example image (from the
* product's `images` array), the title, the summary, and a "View details" hint.
* Wraps the whole tile in a link to the product page.
*
* The full image carousel lives on the product page; this is just the storefront
* teaser. ProductCard.astro (specs-only) is used on the detail pages.
*/
import Icon from '@components/Icon.astro';
import type { Product } from '@data/store-products';
interface Props {
product: Product;
href: string;
}
const { product, href } = Astro.props;
const cover = product.images[0];
---
<a
href={href}
class="group card-interactive flex flex-col gap-4 rounded-2xl border p-4"
style="border-color: var(--color-border); background: var(--color-surface-raised);"
>
{cover && (
<figure class="relative w-full overflow-hidden rounded-xl" style="border: 1px solid var(--color-border);">
<img
src={cover.src}
alt={cover.alt}
loading="lazy"
decoding="async"
class="aspect-[4/3] w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
{product.images.length > 1 && (
<span
class="pointer-events-none absolute right-2 top-2 inline-flex items-center gap-1 rounded-full px-2 py-1 font-mono text-xs"
style="color: var(--color-cream); background: rgba(7,10,28,0.6);"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="9" cy="12" r="1.6" fill="currentColor" stroke="none" />
<circle cx="15" cy="12" r="1.6" fill="currentColor" stroke="none" />
</svg>
{product.images.length}
</span>
)}
</figure>
)}
<div class="flex flex-col gap-2">
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
<Icon name={product.icon} class="h-5 w-5" style="color: var(--color-accent);" />
{product.title}
</span>
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{product.summary}</p>
<span class="inline-flex items-center gap-1 text-sm font-medium transition-colors" style="color: var(--color-accent);">
View details
<Icon name="arrow-right" class="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</span>
</div>
</a>
@@ -0,0 +1,57 @@
---
/**
* RequirementList.astro
* ---------------------------------------------------------------------------
* Renders the store rules / requirements. Each item has an mdi icon that
* signals tone: 'check-decagram' / 'information' = neutral rule,
* 'alert' = caution, 'close-circle' = hard no / final.
*
* Used on the storefront and repeated on the order page; the OrderForm also
* requires an explicit acknowledgment of these.
*/
import Icon from '@components/Icon.astro';
import type { Requirement } from '@data/store-products';
interface Props {
items: Requirement[];
/** Optional heading shown above the list. */
heading?: string;
}
const { items, heading } = Astro.props;
// Map the requirement icon to a tone color.
function toneColor(icon: Requirement['icon']): string {
switch (icon) {
case 'alert':
return '#e8b54a'; // amber — caution
case 'close-circle':
return '#e5655a'; // red — hard no
case 'check-decagram':
case 'information':
default:
return 'var(--color-accent)';
}
}
---
{heading && (
<h3 class="font-sans text-xl font-bold" style="color: var(--color-text);">{heading}</h3>
)}
<ul class="flex flex-col gap-3">
{items.map((item) => (
<li
class="flex gap-3 rounded-lg border p-4"
style="border-color: var(--color-border); background: var(--color-surface-raised);"
>
<span class="mt-0.5 shrink-0" style={`color: ${toneColor(item.icon)};`}>
<Icon name={item.icon} class="h-5 w-5" />
</span>
<div class="flex flex-col gap-0.5">
<span class="font-sans text-sm font-semibold" style="color: var(--color-text);">{item.title}</span>
<span class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{item.body}</span>
</div>
</li>
))}
</ul>
+66
View File
@@ -0,0 +1,66 @@
---
/**
* SlotMeter.astro
* ---------------------------------------------------------------------------
* Visual "X of Y slots open" capacity meter for a store category. Reads a slot
* status (open + total) and renders a progress-style bar plus an open/closed
* pill. The fill width reflects open/total; when open === 0 it shows CLOSED.
*/
import Icon from '@components/Icon.astro';
interface Props {
/** Category label, e.g. "Card orders" or "3D-print slots". */
label: string;
/** mdi icon for the row. */
icon: string;
/** Number of currently open slots. */
open: number;
/** Total slots for the category. */
total: number;
}
const { label, icon, open, total } = Astro.props;
const closed = open <= 0;
const pct = total > 0 ? Math.max(0, Math.min(100, (open / total) * 100)) : 0;
---
<div
class="flex flex-col gap-3 rounded-xl border p-4"
style="border-color: var(--color-border); background: var(--color-surface-raised);"
>
<div class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-2 font-sans text-sm font-semibold" style="color: var(--color-text);">
<Icon name={icon} class="h-5 w-5" style="color: var(--color-accent);" />
{label}
</span>
<span
class="pill"
style={closed
? 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'
: undefined}
>
{closed ? 'Closed' : 'Open'}
</span>
</div>
<div class="flex items-center justify-between text-sm" style="color: var(--color-text-muted);">
<span>{open} of {total} slots open</span>
<span class="font-mono text-xs" style="color: var(--color-text-subtle);">{closed ? '0%' : `${Math.round(pct)}%`}</span>
</div>
<div
class="h-2 w-full overflow-hidden rounded-full"
style="background: color-mix(in srgb, var(--color-border) 60%, transparent);"
role="meter"
aria-valuenow={open}
aria-valuemin="0"
aria-valuemax={total}
aria-label={`${label}: ${open} of ${total} slots open`}
>
<div
class="h-full rounded-full transition-[width] duration-500"
style={`width: ${pct}%; background: ${closed ? '#e55' : 'var(--color-accent)'};`}
>
</div>
</div>
</div>
+137
View File
@@ -0,0 +1,137 @@
---
/**
* StoreNav.astro
* ---------------------------------------------------------------------------
* Store-only top navigation. Deliberately separate from the main NavBar: this
* renders ONLY store links (Storefront, Cards, Deck Boxes, Order) and contains
* NO links back to the main gregkilmer.com site, per Greg's request that the
* two sites not be clearly linked yet.
*
* Visually it mirrors NavBar.astro's treatment — the dark nebula band, the
* scroll blur, and the StarBurst mark — so it reads as the same house while
* being unmistakably a store (a "Store" wordmark + the store.gregkilmer.com
* domain label).
*/
import Icon from '@components/Icon.astro';
import MobileMenu from '@components/nav/MobileMenu.tsx';
import StarBurst from '@components/mystic/StarBurst.astro';
interface NavEntry {
label: string;
href: string;
icon: string;
}
// Single flat group (no "two wings" divider — the store is one thing).
const storeNav: NavEntry[] = [
{ label: 'Store', href: '/store', icon: 'store' },
{ label: 'Cards', href: '/store/cards', icon: 'cards-playing' },
{ label: 'Deck Boxes', href: '/store/deck-boxes', icon: 'printer-3d-nozzle' },
{ label: 'Order', href: '/store/order', icon: 'clipboard-list' },
];
const path = Astro.url.pathname;
const isActive = (href: string) =>
href === '/store' ? path === '/store' : path === href || path.startsWith(href + '/');
// MobileMenu expects the { items: [] }[] shape.
const groups = [{ items: storeNav }];
---
---
<header
id="store-nav"
data-nav
class="fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300"
>
<div class="border-b border-transparent transition-colors duration-300" data-nav-inner>
<nav class="container-prose flex h-16 items-center justify-between gap-4" aria-label="Store">
<!-- Wordmark: "Greg Kilmer · Store" + the store domain label -->
<a href="/store" class="group flex items-center gap-2 leading-tight" aria-label="Greg Kilmer Store — home">
<StarBurst
class="h-4 w-4 shrink-0 transition-transform duration-700 group-hover:rotate-90"
style="color: var(--color-accent);"
/>
<span class="flex flex-col">
<span class="font-sans text-base font-bold tracking-tight" style="color: var(--accent);">
Greg Kilmer · Store
</span>
<span
class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block"
style="color: var(--color-text-subtle);"
>
store.gregkilmer.com
</span>
</span>
</a>
<!-- Desktop nav -->
<div class="hidden items-center gap-1 md:flex">
{storeNav.map((item) => {
const active = isActive(item.href);
return (
<a
href={item.href}
class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors"
style={active ? 'color: var(--color-accent);' : 'color: var(--color-cream); opacity: 0.85;'}
onmouseover="this.style.opacity='1'"
onmouseout="this.style.opacity='0.85'"
aria-current={active ? 'page' : undefined}
>
<Icon name={item.icon} class="h-4 w-4" />
{item.label}
</a>
);
})}
</div>
<!-- Mobile -->
<div class="flex items-center gap-2 md:hidden">
<MobileMenu client:idle groups={groups} currentPath={path} />
</div>
</nav>
</div>
</header>
<style>
/* Same nebula band treatment as the main NavBar. */
[data-nav-inner] {
background: linear-gradient(
180deg,
rgba(8, 10, 32, 0.92) 0%,
rgba(12, 14, 40, 0.86) 100%
);
}
[data-nav].scrolled [data-nav-inner] {
backdrop-filter: saturate(150%) blur(12px);
-webkit-backdrop-filter: saturate(150%) blur(12px);
border-bottom-color: color-mix(
in srgb,
var(--color-accent) 30%,
var(--color-border)
);
box-shadow: 0 8px 30px -20px rgba(11, 16, 38, 0.7);
}
[data-nav]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(60% 120% at 85% -20%, rgba(139, 123, 216, 0.22), transparent 70%),
radial-gradient(50% 120% at 10% -10%, rgba(232, 181, 74, 0.1), transparent 70%);
}
</style>
<script>
// Mirror NavBar's scroll handler. Re-bind on View Transitions navigations.
function setupNavScroll() {
const nav = document.getElementById('store-nav');
if (!nav) return;
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 8);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
}
setupNavScroll();
document.addEventListener('astro:page-load', setupNavScroll);
</script>
+10
View File
@@ -0,0 +1,10 @@
{
"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 so each one gets full attention. Slots reopen as current orders finish.",
"slots": {
"cards": { "open": 0, "total": 5 },
"deckBoxes": { "open": 0, "total": 4 }
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* store-availability.ts
* ---------------------------------------------------------------------------
* Typed reader for store-availability.json. Both the storefront pages and the
* /api/order endpoint read through here, so the hard order gate (ordersOpen)
* is enforced from a single source of truth.
*
* To open orders: edit `./store-availability.json` and set `"ordersOpen": true`,
* then (optionally) bump the `slots.*.open` counts and `lastUpdated`.
*/
import raw from './store-availability.json';
export interface SlotStatus {
open: number;
total: number;
}
export interface StoreAvailability {
/** Master gate. false => order form hidden + /api/order returns 403. */
ordersOpen: boolean;
/** Free-form date string shown as "Updated <date>". */
lastUpdated: string;
/** Turnaround estimate shown on storefront + order page. */
turnaround: string;
/** Note explaining the limited-capacity model. */
maxNote: string;
slots: {
cards: SlotStatus;
deckBoxes: SlotStatus;
};
}
/** The parsed availability, asserted to the shape above. */
export const storeAvailability = raw as StoreAvailability;
/** True when at least one slot is open in a category. */
export function categoryOpen(slot: SlotStatus): boolean {
return slot.open > 0;
}
+216
View File
@@ -0,0 +1,216 @@
/**
* store-products.ts
* ---------------------------------------------------------------------------
* Typed product + pricing data for the store. This is version-controlled source
* (not the user-editable availability file) so the storefront and pricing stay
* in sync with the components that render them.
*
* - Availability (orders open/closed, slot counts, turnaround) lives in
* `./store-availability.json`, which is the simple file Greg edits.
* - Product specs and pricing tiers live HERE.
*
* Prices and concrete specs are deliberately left as [Greg to fill] placeholders
* so real numbers can be dropped in without touching component code.
*/
export interface SpecRow {
/** Label, e.g. "Size". */
label: string;
/** Value, e.g. "63 x 88 mm (standard MTG)". */
value: string;
}
/**
* A single example image for a product. Rendered in the product-page carousel;
* `label` is shown as a caption on/under the image.
*/
export interface ProductImage {
/** Public path, e.g. "/images/proxies.jpg". */
src: string;
/** Alt text for accessibility. */
alt: string;
/** Caption shown on the slide, e.g. "Front finish \u2014 matte". */
label: string;
}
export interface PriceTier {
/** Quantity band label, e.g. "1-9 cards". */
band: string;
/** Price for that band. Placeholder until Greg sets real prices. */
price: string;
/** Optional note, e.g. "per card". */
note?: string;
}
export interface Product {
/** mdi icon used as the decorative mark. */
icon: string;
/** Product name. */
title: string;
/** One-line summary shown on cards + the storefront. */
summary: string;
/**
* Which availability slot category this product belongs to. Used so the
* product page can show the matching SlotMeter at the top.
*/
slotKey: 'cards' | 'deckBoxes';
/**
* Example images for the product-page carousel, in display order. Each slide
* shows the image plus its `label` caption. Add/remove entries here to
* manage the carousel from the server side.
*/
images: ProductImage[];
/** Detailed spec list rendered as a definition list. */
specs: SpecRow[];
/** Pricing tiers rendered as a pricing table. */
pricing: PriceTier[];
}
/**
* Proxy MTG card product line. Sizes/finishes are standard for the format; the
* exact cardstock weights and finish names are Greg's to fill.
*/
export const cardProduct: Product = {
icon: 'cards-playing',
title: 'Proxy MTG Cards',
summary:
'High-quality printed proxy cards for casual play, sized to match real Magic cards and clearly marked as proxies.',
slotKey: 'cards',
images: [
{
src: '/images/proxies.jpg',
alt: 'Example printed proxy MTG cards, front faces',
label: 'Card fronts \u2014 full-color print',
},
{
src: '/images/proxies-example-1.svg',
alt: 'Placeholder: close-up of card finish and cut',
label: 'Placeholder \u2014 close-up of finish & cut',
},
{
src: '/images/proxies-example-2.svg',
alt: 'Placeholder: white card back showing the proxy marking',
label: 'Placeholder \u2014 solid white back',
},
],
specs: [
{ label: 'Size', value: '63 x 88 mm (standard MTG card size)' },
{ label: 'Thickness', value: '[Greg to fill] (e.g. 300 gsm / 350 gsm cardstock)' },
{ label: 'Back', value: 'Solid white back (clearly not tournament-legal)' },
{
label: 'Finishes',
value: '[Greg to fill] (e.g. matte, linen, glossy)',
},
{
label: 'Double-sided cards',
value: 'Printed as two separate cards and count as 2 card slots',
},
{ label: 'Art', value: 'Official card art only. No custom or offensive art (see Requirements)' },
],
pricing: [
{ band: '1-9 cards', price: '[Greg to fill]', note: 'per card' },
{ band: '10-39 cards', price: '[Greg to fill]', note: 'per card' },
{ band: '40-99 cards', price: '[Greg to fill]', note: 'per card' },
{ band: '100+ cards', price: '[Greg to fill]', note: 'per card' },
],
};
/**
* 3D-printed deck box + counters product line.
*/
export const deckBoxProduct: Product = {
icon: 'printer-3d-nozzle',
title: '3D-Printed Deck Boxes & Counters',
summary:
'Custom 3D-printed deck boxes, life counters, and tokens to store and track your games.',
slotKey: 'deckBoxes',
images: [
{
src: '/images/deckboxes.jpg',
alt: 'Example 3D-printed deck boxes and counters',
label: 'Deck box & counters \u2014 assembled',
},
{
src: '/images/deckboxes-example-1.svg',
alt: 'Placeholder: deck box open showing card capacity',
label: 'Placeholder \u2014 open, showing capacity',
},
{
src: '/images/deckboxes-example-2.svg',
alt: 'Placeholder: counter close-up with life totals',
label: 'Placeholder \u2014 counter detail',
},
],
specs: [
{ label: 'Capacity', value: '[Greg to fill] (e.g. 100 double-sleeved cards)' },
{ label: 'Material', value: '[Greg to fill] (e.g. PLA / PETG)' },
{ label: 'Finish', value: '[Greg to fill] (e.g. as-printed, sanded, painted)' },
{ label: 'Sizing', value: '[Greg to fill] (deck box + matching counters)' },
{ label: 'Customization', value: 'Color selection from available filament' },
],
pricing: [
{ band: 'Single deck box', price: '[Greg to fill]', note: 'per print' },
{ band: 'Deck box + counter set', price: '[Greg to fill]', note: 'per set' },
{ band: 'Counter / token only', price: '[Greg to fill]', note: 'per print' },
],
};
/** Both product lines, for storefront iteration. */
export const products = [cardProduct, deckBoxProduct];
/**
* The store requirements / rules. Rendered on the storefront and repeated on
* the order page; the order form requires an explicit acknowledgment of these.
* Wording mirrors what Greg asked to state clearly.
*/
export interface Requirement {
/** mdi icon: 'check' for a rule, 'alert' for a hard limit/warning. */
icon: 'check-decagram' | 'alert' | 'close-circle' | 'information';
/** Short title. */
title: string;
/** One-to-two sentence explanation. */
body: string;
}
export const requirements: Requirement[] = [
{
icon: 'information',
title: 'Card list is final',
body: 'You must provide a complete card list up front. Once the list is received, the cards in that order cannot be changed.',
},
{
icon: 'alert',
title: 'Double-sided cards = 2 slots',
body: 'Double-sided cards are printed as two separate cards (front and back) and count as 2 card slots in your order.',
},
{
icon: 'close-circle',
title: 'All sales are final',
body: 'Payment is final. Because each order is printed to spec, refunds and exchanges are not available.',
},
{
icon: 'alert',
title: 'These are proxies, not counterfeits',
body: 'All cards are clearly marked as proxies with a solid white back. They are not tournament-legal and are not made to pass as genuine.',
},
{
icon: 'close-circle',
title: 'No affiliation with Wizards of the Coast',
body: 'I am not associated with Wizards of the Coast or any game company. Magic: The Gathering and all related properties belong to their respective owners.',
},
{
icon: 'close-circle',
title: 'No counterfeiting',
body: 'I will not counterfeit cards. Every card is produced as a clearly identifiable proxy for casual play.',
},
{
icon: 'alert',
title: 'No custom or offensive art',
body: 'I will not print custom art or any cards I find to be offensive. Only official card art is used.',
},
{
icon: 'information',
title: 'Limited capacity',
body: 'I can only take a limited number of orders at a time. Slots are shown live on the order page and reopen as orders finish.',
},
];
+6 -1
View File
@@ -21,6 +21,7 @@ import "@fontsource-variable/jetbrains-mono";
import { ClientRouter } from "astro:transitions"; import { ClientRouter } from "astro:transitions";
import NavBar from "@components/nav/NavBar.astro"; import NavBar from "@components/nav/NavBar.astro";
import StoreNav from "@components/store/StoreNav.astro";
import Footer from "@components/layout/Footer.astro"; import Footer from "@components/layout/Footer.astro";
import { themeInitScript } from "@/scripts/theme-init"; import { themeInitScript } from "@/scripts/theme-init";
import { siteIdentity } from "@lib/site"; import { siteIdentity } from "@lib/site";
@@ -37,6 +38,9 @@ interface Props {
/** When true, emit <meta name="robots" content="noindex, nofollow">. Use on /** When true, emit <meta name="robots" content="noindex, nofollow">. Use on
pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */ pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */
noindex?: boolean; noindex?: boolean;
/** When true, render the store-only nav (StoreNav) instead of the main NavBar.
Used by store pages, which intentionally share no links with the main site. */
storeNav?: boolean;
} }
const { const {
@@ -45,6 +49,7 @@ const {
surface = "neutral", surface = "neutral",
ogImage, ogImage,
noindex = false, noindex = false,
storeNav = false,
} = Astro.props; } = Astro.props;
// True on the Home page. Used to switch the NavBar's view-transition-name so // True on the Home page. Used to switch the NavBar's view-transition-name so
@@ -134,7 +139,7 @@ const ogImageUrl = ogImage
Skip to content Skip to content
</a> </a>
<NavBar isHome={isHome} /> {storeNav ? <StoreNav /> : <NavBar isHome={isHome} />}
<main id="main"> <main id="main">
<slot /> <slot />
+40
View File
@@ -0,0 +1,40 @@
---
/**
* StoreLayout.astro
* ---------------------------------------------------------------------------
* Thin wrapper around BaseLayout for all store pages. Pins:
* - surface="interest" (warm candlelit tone — clearly a store, still on-brand)
* - storeNav (renders StoreNav, NOT the main NavBar — no cross-links)
* - noindex=true by default (store stays out of search until Greg is ready)
*
* Store pages pass their own title/description; everything else is defaulted
* here so each page stays short.
*/
import BaseLayout from '@layouts/BaseLayout.astro';
interface Props {
title?: string;
description?: string;
ogImage?: string;
/** Override the default noindex=true if you want the store indexed. */
noindex?: boolean;
}
const {
title,
description = 'Greg Kilmer\u2019s store: clearly-marked proxy MTG cards and 3D-printed deck boxes & counters. Order requests by email.',
ogImage,
noindex = true,
} = Astro.props;
---
<BaseLayout
title={title}
description={description}
surface="interest"
storeNav
noindex={noindex}
ogImage={ogImage}
>
<slot />
</BaseLayout>
+6
View File
@@ -79,6 +79,12 @@ export const smtpPass = (): string | undefined => env('SMTP_PASS');
/** Inbox that receives contact form submissions. */ /** Inbox that receives contact form submissions. */
export const contactTo = (): string | undefined => env('CONTACT_TO'); export const contactTo = (): string | undefined => env('CONTACT_TO');
/**
* Inbox that receives store order submissions. Falls back to CONTACT_TO so the
* store works without a separate inbox configured. Used by /api/order.
*/
export const orderTo = (): string | undefined => env('ORDER_TO') ?? contactTo();
/** From address for outgoing mail. */ /** From address for outgoing mail. */
export const smtpFrom = (): string | undefined => env('SMTP_FROM'); export const smtpFrom = (): string | undefined => env('SMTP_FROM');
+238
View File
@@ -0,0 +1,238 @@
/**
* /api/order
* ---------------------------------------------------------------------------
* POST endpoint for store order requests. Pipeline mirrors /api/contact:
* 1. Reject non-POST.
* 2. HARD GATE: read store-availability.json; if ordersOpen is false, return
* 403 immediately. This enforces the lock server-side so it can't be
* bypassed by POSTing directly while the client form is hidden.
* 3. Validate JSON body with Zod.
* 4. Rate-limit per IP (in-memory windowed counter).
* 5. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set).
* 6. Deliver via SMTP (nodemailer) if configured, else webhook, else 503.
*
* This collects an ORDER REQUEST only — no payment is processed. The payload is
* emailed to the configured inbox (ORDER_TO, falling back to CONTACT_TO).
*/
import type { APIRoute } from 'astro';
import { z } from 'zod/v4';
import {
contactWebhookUrl,
env,
orderTo,
smtpFrom,
smtpHost,
smtpPass,
smtpPort,
smtpUser,
turnstileSecretKey,
} from '@lib/env';
import { storeAvailability } from '@data/store-availability';
// --- Validation schema ------------------------------------------------------
const orderTypeEnum = z.enum(['cards', 'deckBoxes', 'both']);
const schema = z.object({
name: z.string().min(1).max(120),
email: z.string().email().max(200),
orderType: orderTypeEnum.default('cards'),
quantity: z.string().min(1).max(200),
details: z.string().min(10).max(20000),
notes: z.string().max(5000).optional().or(z.literal('')),
// Policy acknowledgment checkbox must be ticked.
agree: z.literal(true),
// Honeypot: must be empty.
company: z.string().max(0).optional().or(z.literal('')),
// Turnstile token (required when TURNSTILE_SECRET_KEY is set).
turnstileToken: z.string().optional(),
});
// Human-readable label for the order type, used in the email subject/body.
const orderTypeLabel: Record<string, string> = {
cards: 'Proxy MTG cards',
deckBoxes: '3D-printed deck box / counters',
both: 'Cards + 3D prints',
};
// --- In-memory rate limiter (per worker) ------------------------------------
// Separate counter namespace from /api/contact so order submits don't share
// the contact budget. Tunables read via env helper for dev + Docker parity.
const hits = new Map<string, { count: number; reset: number }>();
function rateLimited(ip: string): boolean {
const rateWindowMin = Number(env('ORDER_RATE_WINDOW_MIN') ?? env('CONTACT_RATE_WINDOW_MIN') ?? 15);
const rateLimit = Number(env('ORDER_RATE_LIMIT') ?? env('CONTACT_RATE_LIMIT') ?? 5);
const now = Date.now();
const entry = hits.get(ip);
if (!entry || now > entry.reset) {
hits.set(ip, { count: 1, reset: now + rateWindowMin * 60_000 });
return false;
}
entry.count += 1;
return entry.count > rateLimit;
}
// --- Helpers ----------------------------------------------------------------
function json(body: unknown, status: number) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function clientIp(request: Request): string {
const fwd = request.headers.get('x-forwarded-for');
if (fwd) return fwd.split(',')[0]!.trim();
return request.headers.get('x-real-ip') ?? 'unknown';
}
// --- Turnstile verification (same logic as /api/contact) --------------------
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
const secret = turnstileSecretKey();
if (!secret) return true; // Turnstile not configured -> skip.
if (!token) return false;
try {
const form = new URLSearchParams();
form.set('secret', secret);
form.set('response', token);
if (ip && ip !== 'unknown') form.set('remoteip', ip);
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: form,
});
const data = (await res.json()) as { success: boolean };
return Boolean(data.success);
} catch (err) {
console.error('[order] turnstile verification failed:', err);
return false;
}
}
// --- Route handler ----------------------------------------------------------
export const POST: APIRoute = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed.' }, 405);
}
// HARD GATE: orders closed -> reject before any other work. Re-reading the
// JSON module here reflects whatever Greg committed to the file at deploy.
if (!storeAvailability.ordersOpen) {
return json({ error: 'Orders are currently closed. Please check back soon.' }, 403);
}
// Parse + validate.
let payload: unknown;
try {
payload = await request.json();
} catch {
return json({ error: 'Invalid JSON body.' }, 400);
}
const parsed = schema.safeParse(payload);
if (!parsed.success) {
return json({ error: 'Please complete all fields correctly, including the policy acknowledgment.', issues: parsed.error.issues }, 400);
}
const { name, email, orderType, quantity, details, notes, turnstileToken } = parsed.data;
// Rate limit.
const ip = clientIp(request);
if (rateLimited(ip)) {
return json({ error: 'Too many submissions. Please try again later.' }, 429);
}
// Turnstile.
const turnstileOk = await verifyTurnstile(turnstileToken, ip);
if (!turnstileOk) {
return json({ error: 'Bot check failed. Please refresh and try again.' }, 400);
}
// Deliver.
try {
const delivered = await deliver({ name, email, orderType, quantity, details, notes: notes ?? '' });
if (!delivered) {
return json({ error: 'Order delivery is not configured yet. Please email me directly.' }, 503);
}
return json({ ok: true }, 200);
} catch (err) {
console.error('[order] delivery failed:', err);
return json({ error: 'Could not send your order right now. Please try again later.' }, 502);
}
};
// --- Delivery: SMTP first, webhook fallback ---------------------------------
interface OrderMessage {
name: string;
email: string;
orderType: string;
quantity: string;
details: string;
notes: string;
}
async function deliver(msg: OrderMessage): Promise<boolean> {
const host = smtpHost();
const webhook = contactWebhookUrl();
const typeLabel = orderTypeLabel[msg.orderType] ?? msg.orderType;
// Plain-text body for the email.
const text = [
`New store order request`,
``,
`Name: ${msg.name}`,
`Email: ${msg.email}`,
`Order type: ${typeLabel}`,
`Approx quantity: ${msg.quantity}`,
``,
`Details:`,
msg.details,
``,
msg.notes ? `Notes:\n${msg.notes}` : '',
].join('\n');
if (host) {
const nodemailer = (await import('nodemailer')).default;
const port = Number(smtpPort() ?? 587);
const user = smtpUser();
const allowSelfSigned = env('SMTP_ALLOW_SELFSIGNED') === 'true';
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user ? { user, pass: smtpPass() } : undefined,
...(allowSelfSigned ? { tls: { rejectUnauthorized: false } } : {}),
});
const hostname = (() => {
try {
return new URL(process.env.SITE_URL ?? 'https://gregkilmer.com').hostname;
} catch {
return 'gregkilmer.com';
}
})();
await transport.sendMail({
from: smtpFrom() ?? `Greg's Store <no-reply@${hostname}>`,
to: orderTo() ?? 'greg@gregkilmer.com',
replyTo: `${msg.name} <${msg.email}>`,
subject: `New store order — ${typeLabel} (${msg.name})`,
text,
});
return true;
}
if (webhook) {
const res = await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New store order from ${msg.name} (${msg.email}) — ${typeLabel}`,
name: msg.name,
email: msg.email,
orderType: typeLabel,
quantity: msg.quantity,
details: msg.details,
notes: msg.notes,
}),
});
return res.ok;
}
return false;
}
+126
View File
@@ -0,0 +1,126 @@
---
/**
* /store/cards — Proxy MTG cards product line
* ---------------------------------------------------------------------------
* Order: back-link + title → live slot tracker → image carousel → specs →
* pricing → double-sided callout → key requirements → CTA.
*
* The carousel images + labels are managed server-side in store-products.ts
* (cardProduct.images). The specs block is image-free now; examples live in
* the carousel only.
*/
import StoreLayout from '@layouts/StoreLayout.astro';
import Reveal from '@components/motion/Reveal.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import Icon from '@components/Icon.astro';
import Carousel from '@components/store/Carousel.tsx';
import ProductCard from '@components/store/ProductCard.astro';
import PricingTable from '@components/store/PricingTable.astro';
import SlotMeter from '@components/store/SlotMeter.astro';
import RequirementList from '@components/store/RequirementList.astro';
import { cardProduct, requirements } from '@data/store-products';
import { storeAvailability } from '@data/store-availability';
const av = storeAvailability;
const slot = av.slots[cardProduct.slotKey];
// The double-sided rule is important enough to surface as its own callout.
const doubleSided = requirements.find((r) => r.title.toLowerCase().includes('double-sided'))!;
const keyReqs = requirements.filter((r) =>
r.title.toLowerCase().includes('card list')
|| r.title.toLowerCase().includes('double-sided')
|| r.title.toLowerCase().includes('proxies')
|| r.title.toLowerCase().includes('custom'),
);
---
<StoreLayout
title="Proxy MTG Cards"
description="Proxy MTG cards printed to standard size with a solid white back — clearly marked proxies for casual play. Specs and pricing."
>
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
{/* Header */}
<section class="flex flex-col gap-5">
<Reveal>
<a
href="/store"
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
>
<Icon name="arrow-left" class="h-4 w-4" />
Back to store
</a>
</Reveal>
<Reveal delay={60}>
<SectionHeader kicker="Product" icon={cardProduct.icon} title={cardProduct.title} subtitle={cardProduct.summary} />
</Reveal>
</section>
{/* Live slot tracker for this product line (top of content). */}
<section class="flex flex-col gap-3">
<Reveal>
<SlotMeter label="Card order slots" icon="cards-playing" open={slot.open} total={slot.total} />
</Reveal>
</section>
{/* Image carousel (server-driven, swipeable). */}
<section class="flex flex-col gap-4">
<Reveal><SectionHeader kicker="Examples" icon="image-multiple" title={`See the ${cardProduct.title.toLowerCase()}`} /></Reveal>
<Reveal delay={60}>
<Carousel client:visible slides={cardProduct.images} label={`${cardProduct.title} examples`} />
</Reveal>
</section>
{/* Specifications (no image — examples live in the carousel). */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Details" icon="ruler" title="Specifications" /></Reveal>
<Reveal delay={60}>
<ProductCard product={cardProduct} detailed />
</Reveal>
</section>
{/* Pricing */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Pricing" icon="currency-usd" title="Card sets" /></Reveal>
<Reveal delay={60}>
<PricingTable
icon="cards-playing"
caption="Per-card pricing by quantity"
subtitle="Final pricing confirmed when your list is received. Prices are placeholders pending update."
tiers={cardProduct.pricing}
/>
</Reveal>
</section>
{/* Double-sided callout */}
<Reveal>
<div
class="flex items-start gap-4 rounded-xl border p-5"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
>
<Icon name="alert" class="mt-0.5 h-6 w-6 shrink-0" style="color: var(--color-accent);" />
<div class="flex flex-col gap-1">
<span class="font-sans text-base font-bold" style="color: var(--color-text);">{doubleSided.title}</span>
<span class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{doubleSided.body}</span>
</div>
</div>
</Reveal>
{/* Key requirements */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Before you order" icon="clipboard-check" title="Key requirements" /></Reveal>
<Reveal delay={60}>
<RequirementList items={keyReqs} />
</Reveal>
</section>
{/* CTA */}
<Reveal>
<div class="flex flex-col items-center gap-4 text-center">
<a href="/store/order" class="btn btn-primary">
<Icon name="clipboard-list" class="h-5 w-5" />
{av.ordersOpen ? 'Start an order request' : 'View order page'}
</a>
</div>
</Reveal>
</div>
</StoreLayout>
+109
View File
@@ -0,0 +1,109 @@
---
/**
* /store/deck-boxes — 3D-printed deck boxes & counters product line
* ---------------------------------------------------------------------------
* Order: back-link + title → live slot tracker → image carousel → specs →
* pricing → key requirements → CTA.
*
* The carousel images + labels are managed server-side in store-products.ts
* (deckBoxProduct.images). The specs block is image-free now; examples live in
* the carousel only.
*/
import StoreLayout from '@layouts/StoreLayout.astro';
import Reveal from '@components/motion/Reveal.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import Icon from '@components/Icon.astro';
import Carousel from '@components/store/Carousel.tsx';
import ProductCard from '@components/store/ProductCard.astro';
import PricingTable from '@components/store/PricingTable.astro';
import SlotMeter from '@components/store/SlotMeter.astro';
import RequirementList from '@components/store/RequirementList.astro';
import { deckBoxProduct, requirements } from '@data/store-products';
import { storeAvailability } from '@data/store-availability';
const av = storeAvailability;
const slot = av.slots[deckBoxProduct.slotKey];
const keyReqs = requirements.filter((r) =>
r.title.toLowerCase().includes('final')
|| r.title.toLowerCase().includes('capacity')
|| r.title.toLowerCase().includes('affiliation'),
);
---
<StoreLayout
title="3D-Printed Deck Boxes & Counters"
description="3D-printed deck boxes, life counters, and tokens. Specs and pricing for printed storage and trackers."
>
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
{/* Header */}
<section class="flex flex-col gap-5">
<Reveal>
<a
href="/store"
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
>
<Icon name="arrow-left" class="h-4 w-4" />
Back to store
</a>
</Reveal>
<Reveal delay={60}>
<SectionHeader kicker="Product" icon={deckBoxProduct.icon} title={deckBoxProduct.title} subtitle={deckBoxProduct.summary} />
</Reveal>
</section>
{/* Live slot tracker for this product line (top of content). */}
<section class="flex flex-col gap-3">
<Reveal>
<SlotMeter label="3D-print slots" icon="printer-3d-nozzle" open={slot.open} total={slot.total} />
</Reveal>
</section>
{/* Image carousel (server-driven, swipeable). */}
<section class="flex flex-col gap-4">
<Reveal><SectionHeader kicker="Examples" icon="image-multiple" title={`See the ${deckBoxProduct.title.toLowerCase()}`} /></Reveal>
<Reveal delay={60}>
<Carousel client:visible slides={deckBoxProduct.images} label={`${deckBoxProduct.title} examples`} />
</Reveal>
</section>
{/* Specifications (no image — examples live in the carousel). */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Details" icon="ruler" title="Specifications" /></Reveal>
<Reveal delay={60}>
<ProductCard product={deckBoxProduct} detailed />
</Reveal>
</section>
{/* Pricing */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Pricing" icon="currency-usd" title="Prints" /></Reveal>
<Reveal delay={60}>
<PricingTable
icon="printer-3d-nozzle"
caption="Per-print pricing"
subtitle="Final pricing confirmed when your request is received. Prices are placeholders pending update."
tiers={deckBoxProduct.pricing}
/>
</Reveal>
</section>
{/* Key requirements */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Before you order" icon="clipboard-check" title="Key requirements" /></Reveal>
<Reveal delay={60}>
<RequirementList items={keyReqs} />
</Reveal>
</section>
{/* CTA */}
<Reveal>
<div class="flex flex-col items-center gap-4 text-center">
<a href="/store/order" class="btn btn-primary">
<Icon name="clipboard-list" class="h-5 w-5" />
{av.ordersOpen ? 'Start an order request' : 'View order page'}
</a>
</div>
</Reveal>
</div>
</StoreLayout>
+206
View File
@@ -0,0 +1,206 @@
---
/**
* /store — Storefront
* ---------------------------------------------------------------------------
* The store home: hero on the animated field, intro, the two product lines,
* live availability (slots) from store-availability.json, the disclaimer /
* requirements block, and a CTA to the order page.
*
* Reuses the main site's aesthetic (InterestHero-style hero, Reveal, cards)
* but is rendered through StoreLayout so it gets the store-only nav and
* noindex by default. No links back to the main site.
*/
import StoreLayout from '@layouts/StoreLayout.astro';
import Reveal from '@components/motion/Reveal.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
import StarBurst from '@components/mystic/StarBurst.astro';
import Icon from '@components/Icon.astro';
import ProductTile from '@components/store/ProductTile.astro';
import SlotMeter from '@components/store/SlotMeter.astro';
import RequirementList from '@components/store/RequirementList.astro';
import { cardProduct, deckBoxProduct, requirements } from '@data/store-products';
import { storeAvailability } from '@data/store-availability';
const av = storeAvailability;
const orderHref = '/store/order';
---
<StoreLayout
title="Store — Proxy MTG Cards & 3D-Printed Deck Boxes"
>
{/* --- Hero --- */}
<section class="relative overflow-hidden pb-8 pt-28 sm:pt-32">
<AnimatedBackground client:idle className="vt-sky" />
<div class="container-prose relative z-10 grid items-center gap-6 py-6 sm:grid-cols-[1.4fr_1fr]">
<div class="flex flex-col items-start gap-4">
<Reveal>
<span
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
>
<Icon name="store" class="h-4 w-4" />
store.gregkilmer.com
</span>
</Reveal>
<Reveal delay={60}>
<span
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name="cards" class="h-6 w-6" />
</span>
</Reveal>
<Reveal delay={100}>
<h2
class="font-sans font-bold tracking-tight"
style="color: var(--color-cream); font-size: clamp(1.75rem, 2vw + 1rem, 2.75rem); line-height: 1.15;"
>
Proxy MTG cards &amp; 3D-printed deck boxes
</h2>
</Reveal>
<Reveal delay={140}>
<p
class="max-w-xl border-l-2 pl-4 font-sans text-base italic sm:text-lg"
style="border-color: var(--color-accent); color: var(--color-cream); opacity: 0.92;"
>
Clearly-marked proxies for casual play, and matching 3D-printed storage &amp;
counters. Hand-made in small batches.
</p>
</Reveal>
<Reveal delay={180}>
<a href={orderHref} class="btn btn-primary mt-2">
<Icon name="clipboard-list" class="h-5 w-5" />
Start an order request
</a>
</Reveal>
</div>
<Reveal delay={80} class="w-full">
<figure
class="relative w-full overflow-hidden rounded-2xl border"
style="border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);"
>
<img
src="/images/proxies.jpg"
alt="Example printed proxy MTG cards"
loading="lazy"
decoding="async"
class="aspect-[4/3] w-full object-cover"
/>
<StarBurst
class="pointer-events-none absolute left-3 top-3 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-accent)_60%,transparent)]"
style="color: var(--color-accent);"
/>
</figure>
</Reveal>
</div>
</section>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* --- Intro + limited capacity banner --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader kicker="Welcome" icon="store" title="A small workshop, not a factory" />
</Reveal>
<Reveal delay={60}>
<p class="max-w-2xl text-lg leading-relaxed" style="color: var(--color-text-muted);">
{av.maxNote}
</p>
</Reveal>
<Reveal delay={100}>
<div
class="flex flex-col gap-3 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
>
<div class="flex items-center gap-3">
<Icon name="clock-outline" class="h-6 w-6 shrink-0" style="color: var(--color-accent);" />
<span class="text-sm sm:text-base" style="color: var(--color-text);">{av.turnaround}</span>
</div>
<span class="pill" style={av.ordersOpen ? undefined : 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'}>
{av.ordersOpen ? 'Orders open' : 'Orders currently closed'}
</span>
</div>
</Reveal>
</section>
{/* --- Product lines --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader kicker="Products" icon="cards-outline" title="What I make" />
</Reveal>
<div class="grid gap-5 sm:grid-cols-2">
<Reveal delay={60}>
<ProductTile product={cardProduct} href="/store/cards" />
</Reveal>
<Reveal delay={120}>
<ProductTile product={deckBoxProduct} href="/store/deck-boxes" />
</Reveal>
</div>
</section>
{/* --- Live availability --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader kicker="Availability" icon="gauge" title="Current order slots" />
</Reveal>
<Reveal delay={60}>
<p class="max-w-2xl text-base" style="color: var(--color-text-muted);">
I can only take so many requests at a time. These update as I open and finish orders.
Last updated {av.lastUpdated}.
</p>
</Reveal>
<Reveal delay={100}>
<div class="grid gap-4 sm:grid-cols-2">
<SlotMeter
label="Card orders"
icon="cards-playing"
open={av.slots.cards.open}
total={av.slots.cards.total}
/>
<SlotMeter
label="3D-print slots"
icon="printer-3d-nozzle"
open={av.slots.deckBoxes.open}
total={av.slots.deckBoxes.total}
/>
</div>
</Reveal>
</section>
{/* --- Requirements / disclaimer --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader kicker="Before you order" icon="clipboard-check" title="Requirements &amp; policies" />
</Reveal>
<Reveal delay={60}>
<RequirementList items={requirements} />
</Reveal>
</section>
{/* --- CTA --- */}
<section class="flex flex-col items-center gap-4 rounded-2xl border p-8 text-center" style="border-color: var(--color-border); background: var(--color-surface-raised);">
<Reveal>
<h3 class="font-sans text-2xl font-bold" style="color: var(--color-text);">Ready to put in a request?</h3>
</Reveal>
<Reveal delay={60}>
<p class="max-w-lg text-sm" style="color: var(--color-text-muted);">
The order form collects your request and emails it to me. No payment is taken through
the site. {av.ordersOpen ? '' : 'Orders are currently closed — check back soon.'}
</p>
</Reveal>
<Reveal delay={100}>
<a href={orderHref} class="btn btn-primary">
<Icon name="clipboard-list" class="h-5 w-5" />
Go to the order page
</a>
</Reveal>
</section>
</div>
</StoreLayout>
+140
View File
@@ -0,0 +1,140 @@
---
/**
* /store/order — Order request page
* ---------------------------------------------------------------------------
* Hard-gated by store-availability.json:
* - ordersOpen === false => renders a "closed" notice + email link, NO form.
* - ordersOpen === true => renders the OrderForm island + slot meters +
* the requirements list.
*
* The server endpoint /api/order re-checks the same file and returns 403 when
* closed, so the gate can't be bypassed by POSTing directly.
*/
import StoreLayout from '@layouts/StoreLayout.astro';
import Reveal from '@components/motion/Reveal.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import Icon from '@components/Icon.astro';
import ObfuscatedEmail from '@components/ObfuscatedEmail.astro';
import OrderForm from '@components/store/OrderForm.tsx';
import SlotMeter from '@components/store/SlotMeter.astro';
import RequirementList from '@components/store/RequirementList.astro';
import { requirements } from '@data/store-products';
import { storeAvailability } from '@data/store-availability';
import { turnstileSiteKey } from '@lib/env';
const av = storeAvailability;
const open = av.ordersOpen;
const siteKey = turnstileSiteKey();
---
<StoreLayout
title="Order Request"
description="Submit a proxy-card or 3D-print order request. No payment is taken through the site."
>
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
<section class="flex flex-col gap-5">
<Reveal>
<a
href="/store"
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
>
<Icon name="arrow-left" class="h-4 w-4" />
Back to store
</a>
</Reveal>
<Reveal delay={60}>
<SectionHeader kicker="Order" icon="clipboard-list" title="Order request" />
</Reveal>
<Reveal delay={100}>
<p class="max-w-2xl text-lg leading-relaxed" style="color: var(--color-text-muted);">
This form sends me an order request by email. No payment is taken through the site.
{av.turnaround}
</p>
</Reveal>
</section>
{/* Availability meters — shown first so visitors immediately see whether
there's capacity before reading the rest of the page. */}
<section class="flex flex-col gap-5">
<Reveal><SectionHeader kicker="Availability" icon="gauge" title="Current order slots" /></Reveal>
<Reveal delay={60}>
<div class="grid gap-4 sm:grid-cols-2">
<SlotMeter label="Card orders" icon="cards-playing" open={av.slots.cards.open} total={av.slots.cards.total} />
<SlotMeter label="3D-print slots" icon="printer-3d-nozzle" open={av.slots.deckBoxes.open} total={av.slots.deckBoxes.total} />
</div>
</Reveal>
{/* Open/closed status banner (under the meters). */}
<Reveal delay={100}>
<div
class="flex flex-col gap-3 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
style={open
? 'border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);'
: 'border-color: color-mix(in srgb, #e55 40%, transparent); background: color-mix(in srgb, #e55 10%, transparent);'}
>
<div class="flex items-center gap-3">
<Icon name={open ? 'check-circle' : 'close-circle'} class="h-6 w-6 shrink-0" style={open ? 'color: var(--color-accent);' : 'color: #e55;'} />
<span class="text-sm sm:text-base" style="color: var(--color-text);">
{open ? 'Orders are open. Slots are limited.' : 'Orders are currently closed.'}
</span>
</div>
<span class="font-mono text-xs" style="color: var(--color-text-subtle);">
Updated {av.lastUpdated}
</span>
</div>
</Reveal>
</section>
{/* Either the form (open) or the closed notice (no form). */}
{open ? (
<section class="flex flex-col gap-8 lg:grid lg:grid-cols-[1.6fr_1fr] lg:items-start">
<Reveal>
<div class="card flex flex-col gap-2">
<h3 class="font-sans text-lg font-bold" style="color: var(--color-text);">Your request</h3>
<p class="text-sm" style="color: var(--color-text-muted);">
Fill in the details below. Remember: the card list is final once received, and
double-sided cards count as 2 slots.
</p>
<div class="mt-3">
<OrderForm client:idle turnstileSiteKey={siteKey} />
</div>
</div>
</Reveal>
<Reveal delay={80}>
<div class="flex flex-col gap-4 lg:sticky lg:top-24">
<RequirementList items={requirements} heading="Policies &amp; requirements" />
</div>
</Reveal>
</section>
) : (
<section class="flex flex-col gap-6">
<Reveal>
<div
class="flex flex-col items-center gap-4 rounded-2xl border p-8 text-center"
style="border-color: color-mix(in srgb, #e55 40%, transparent); background: color-mix(in srgb, #e55 8%, transparent);"
>
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full" style="background: color-mix(in srgb, #e55 20%, transparent); color: #e55;" aria-hidden="true">
<Icon name="lock" class="h-6 w-6" />
</span>
<h3 class="font-sans text-xl font-bold" style="color: var(--color-text);">Orders are not being taken right now</h3>
<p class="max-w-md text-sm" style="color: var(--color-text-muted);">
I open a limited number of slots at a time. Check back here &mdash; the meters above
show live availability, and the order form appears as soon as orders reopen. You can
also email me directly with questions.
</p>
<div class="mt-1 flex flex-wrap items-center justify-center gap-2">
<Icon name="email" class="h-5 w-5" style="color: var(--color-accent);" />
<ObfuscatedEmail address="orders@gregkilmer.com" class="font-medium" style="color: var(--color-accent);" />
</div>
</div>
</Reveal>
<Reveal delay={60}>
<RequirementList items={requirements} heading="Policies &amp; requirements (for when orders open)" />
</Reveal>
</section>
)}
</div>
</StoreLayout>
+2 -1
View File
@@ -8,7 +8,8 @@
"@/*": ["src/*"], "@/*": ["src/*"],
"@components/*": ["src/components/*"], "@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"], "@layouts/*": ["src/layouts/*"],
"@lib/*": ["src/lib/*"] "@lib/*": ["src/lib/*"],
"@data/*": ["src/data/*"]
}, },
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"allowJs": true, "allowJs": true,