Merge pull request 'Merge store to master' (#1) from store into master

Reviewed-on: patiun/greg_profile_site#1
This commit is contained in:
2026-08-06 21:49:07 +00:00
49 changed files with 3708 additions and 706 deletions
+7
View File
@@ -36,6 +36,13 @@ SMTP_ALLOW_SELFSIGNED=
# Optional webhook fallback (JSON POST). Only used if SMTP_HOST is unset.
# CONTACT_WEBHOOK_URL=
# --- Store order form -> /api/order -----------------------------------------
# Reuses the SMTP/relay settings above. ORDER_TO is the inbox that receives
# order requests; if blank it falls back to CONTACT_TO. No payment is taken;
# the form only collects an order request that is emailed to you. The order
# form is hard-gated by src/data/store-availability.json (ordersOpen).
ORDER_TO=orders@gregkilmer.com
# Honeypot / basic abuse control. Maximum submissions per IP per window.
CONTACT_RATE_LIMIT=5
CONTACT_RATE_WINDOW_MIN=15
@@ -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.
+21
View File
@@ -28,6 +28,27 @@ const SITE_URL = process.env.SITE_URL ?? 'https://gregkilmer.com';
const PORT = Number(process.env.PORT ?? 4321);
const HOST = process.env.HOST ?? '0.0.0.0';
/**
* React-refresh preamble, injected into the layout <head> in dev (see
* BaseLayout.astro). Returned as a string so the layout can emit it as an
* inline module script that runs BEFORE any island module evaluates.
*
* Why this is needed: Vite 8 (Rolldown) + @vitejs/plugin-react 5.x does not
* inject this preamble through Astro's SSR dev HTML pipeline, so the compiled
* islands call $RefreshSig$() at module-eval time, throw, and never hydrate —
* surfacing as "_jsxDEV is not a function" and leaving every React island
* (carousel, mobile menu, contact/order forms, animated background) dead.
*
* Production builds are unaffected: the client bundle uses jsx/jsxs directly
* and never touches $RefreshSig$. Remove this once @astrojs/react ships
* plugin-react 6, which handles Rolldown natively.
*/
export const REACT_REFRESH_PREAMBLE =
"import { injectIntoGlobalHook } from '/@react-refresh';\n" +
'injectIntoGlobalHook(window);\n' +
'window.$RefreshReg$ = () => {};\n' +
'window.$RefreshSig$ = () => (type) => type;';
export default defineConfig({
// Used for canonical URLs, sitemaps, and absolute metadata. Keep this in sync with the
// deployed domain on your Cosmos reverse proxy.
+151
View File
@@ -0,0 +1,151 @@
# 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.
### Available print colors (deck boxes)
The deck-box product has a `colors` array — this drives the **Available print
colors** swatch grid on `/store/deck-boxes` and the color dropdown in the order
form. Each entry is `{ name, hex, inStock }`:
```ts
colors: [
{ name: 'Midnight Black', hex: '#1b1730', inStock: true },
{ name: 'Nebula Teal', hex: '#4f8c84', inStock: false }, // shows "Out of stock"
],
```
- **Add a color** → append a new `{ name, hex, inStock }` entry.
- **Mark a color sold out** → set `inStock: false`. It shows an "Out of stock"
label on the grid and drops out of the order-form dropdown automatically.
- **Remove a color** → delete the entry.
Only in-stock colors are offered in the order form; the chosen color is included
in the order email. Colors are optional — products with no `colors` array (e.g.
proxy cards) simply don't show the grid or the dropdown.
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`
+862 -702
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -18,7 +18,7 @@
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^7.0.2",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@astrojs/react": "^6.0.2",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
@@ -27,7 +27,7 @@
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^7.0.6",
"astro": "^7.2.0",
"framer-motion": "^12.42.2",
"nodemailer": "^9.0.3",
"react": "^19.2.7",
+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+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',
email:
'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm8 7L4 6v1l8 5 8-5V6l-8 5z',
// Store nav icons (kept here so the store mobile menu is self-contained).
store: 'M4 7h16l-1 13H5L4 7zm5-3a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0 3 3',
'cards-playing': 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm3 4v3m6 7v-3',
'printer-3d-nozzle':
'M7 2h10v4l3 3-4 2H8L4 9l3-3V2zm5 11v9',
'clipboard-list':
'M9 3h6v2H9V3zm-2 0v2H6a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-1V3M7 11h2m4 0h4M7 15h2m4 0h4',
};
function Icon({ name, className }: { name: string; className?: string }) {
+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>
);
}
+69
View File
@@ -0,0 +1,69 @@
---
/**
* ColorGrid.astro
* ---------------------------------------------------------------------------
* Renders the available print/filament colors for a product as a grid of
* swatches. Each swatch shows the color, its name, and an In-stock / Out-of-
* stock pill.
*
* The data comes from the product's `colors` array in store-products.ts. To
* manage colors, edit that array (add/remove entries, toggle `inStock`).
*
* If `colors` is omitted or empty, this component renders nothing.
*/
import type { ColorOption } from '@data/store-products';
interface Props {
colors?: ColorOption[];
}
const { colors = [] } = Astro.props;
const inStockCount = colors.filter((c) => c.inStock).length;
---
{colors.length > 0 && (
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<span class="text-sm" style="color: var(--color-text-muted);">
Choose from the available filament colors.
</span>
<span class="font-mono text-xs" style="color: var(--color-text-subtle);">
{inStockCount} of {colors.length} in stock
</span>
</div>
<ul class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{colors.map((c) => (
<li
class:list={[
'flex items-center gap-3 rounded-xl border p-3',
!c.inStock && 'opacity-60',
]}
style="border-color: var(--color-border); background: var(--color-surface-raised);"
>
{/* Swatch. A hairline ring keeps light colors (e.g. bone white)
visible against the raised surface. */}
<span
class="h-9 w-9 shrink-0 rounded-full"
style={`background: ${c.hex}; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 18%, transparent);`}
aria-hidden="true"
>
</span>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-sans text-sm font-semibold" style="color: var(--color-text);">
{c.name}
</span>
<span
class="pill text-[0.7rem]"
style={c.inStock
? undefined
: 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'}
>
{c.inStock ? 'In stock' : 'Out of stock'}
</span>
</span>
</li>
))}
</ul>
</div>
)}
+349
View File
@@ -0,0 +1,349 @@
/**
* 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 ColorOption {
name: string;
hex: string;
inStock: boolean;
}
interface Props {
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
turnstileSiteKey?: string;
/**
* Available filament colors for printed items, from store-products.ts. Only
* in-stock colors are offered in the color dropdown. Omit to hide the color
* selector entirely (e.g. if no colors are configured).
*/
colors?: ColorOption[];
}
export default function OrderForm({ turnstileSiteKey, colors = [] }: Props) {
const [status, setStatus] = useState<Status>('idle');
const [errors, setErrors] = useState<FieldErrors>({});
const [errorMsg, setErrorMsg] = useState<string>('');
// Tracks the order type so the color selector can show/hide. Mirrors the
// <select name="orderType"> so the two stay in sync.
const [orderType, setOrderType] = useState<OrderType>('cards');
// Colors currently offered = in-stock only. Empty when none configured.
const availableColors = colors.filter((c) => c.inStock);
const showColors = availableColors.length > 0 && (orderType === 'deckBoxes' || orderType === 'both');
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;
color: string;
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),
color: String(data.get('color') ?? '').trim(),
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"
value={orderType}
onChange={(e) => setOrderType(e.target.value as OrderType)}
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>
{/* Color selector — only for printed items, only when colors are
configured and in stock. The list comes from store-products.ts. */}
{showColors && (
<div className="flex flex-col">
<label htmlFor="color" className={labelBase} style={fieldColor}>Print color</label>
<select id="color" name="color" defaultValue="" className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor }}>
<option value="" disabled>Select a color&hellip;</option>
{availableColors.map((c) => (
<option key={c.name} value={c.name}>{c.name}</option>
))}
</select>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
Colors shown are what&rsquo;s loaded right now. See the deck-boxes page for swatches.
</p>
</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>
+141
View File
@@ -0,0 +1,141 @@
---
/**
* 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>
/* The store nav uses a SOLID opaque band (not the main NavBar's translucent
gradient), because the store index hero renders a bright animated field
plus a gold label right at the top edge. A translucent nav let that hero
content bleed through, making the nav read as if it sat behind the hero.
Solid + a hairline border keeps it a clearly distinct bar at all times. */
[data-nav-inner] {
background: linear-gradient(
180deg,
rgb(8, 10, 32) 0%,
rgb(12, 14, 40) 100%
);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
}
[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>
+16
View File
@@ -0,0 +1,16 @@
{
"ordersOpen": false,
"lastUpdated": "2026-08-06",
"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": 1,
"total": 3
},
"deckBoxes": {
"open": 2,
"total": 3
}
}
}
+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;
}
+299
View File
@@ -0,0 +1,299 @@
/**
* 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;
}
/**
* A selectable filament/print color for a printed product. Rendered as a
* swatch on the product page and offered in the order form. Flip `inStock` to
* false when a color runs out — it shows as "Out of stock" and is removed from
* the order-form color list.
*/
export interface ColorOption {
/** Display name, e.g. "Midnight Black". */
name: string;
/** Hex swatch color shown in the grid + form, e.g. "#1b1730". */
hex: string;
/** Whether the color is currently available to order. */
inStock: boolean;
}
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[];
/**
* Optional selectable print colors (filament). Rendered as a swatch grid on
* the product page and offered in the order form. Add/remove entries here to
* manage available colors; toggle `inStock` per color. Omit for products that
* have no color choice (e.g. proxy cards).
*/
colors?: ColorOption[];
}
/**
* 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 intended for use sleeved.',
slotKey: 'cards',
images: [
{
src: '/images/store/cards/proxies.jpg',
alt: 'Example printed proxy MTG cards, front faces',
label: 'Card fronts \u2014 full-color print',
},{
src: '/images/store/cards/proxies2.jpg',
alt: 'Example printed foil proxy MTG cards, front faces',
label: 'Card fronts \u2014 foil print',
},{
src: '/images/store/cards/foil_cloud_measured.jpg',
alt: 'Example printed foil cloud proxy card with measurements',
label: 'Rainbow foil card front with measurements',
},{
src: '/images/store/cards/disclosure.jpg',
alt: 'Example printed deck disclosure card',
label: 'Deck disclosure card',
},{
src: '/images/store/cards/sleeved.jpg',
alt: 'Example printed proxy cards in sleeves',
label: 'Proxy cards in sleeves',
},{
src: '/images/store/cards/ravenous_duo.jpg',
alt: 'Example printed Ravenous TRex foil and non-foil proxy cards',
label: 'Ravenous TRex foil and non-foil proxy cards',
},{
src: '/images/store/cards/ghalta_duo.jpg',
alt: 'Example printed Ghalta foil and non-foil proxy cards',
label: 'Ghalta foil and non-foil proxy cards',
},
],
specs: [
{ label: 'Size', value: '63 x 88 mm (standard MTG card size)' },
{ label: 'Thickness', value: '0.37mm laminated photo cardstock' },
{ label: 'Back', value: 'Solid white back (clearly not tournament-legal)' },
{
label: 'Finishes',
value: 'Default matte finish, available in glossy on request (Foil cards are always glossy finish)',
},
{
label: 'Double-sided cards',
value: 'Printed as two separate cards and count as 2 card slots',
},
{ label: 'Art', value: 'Official card art only. No offensive art (custom cards usable if fully provided)' },
],
pricing: [
{ band: 'Individual cards', price: '$1.00', note: 'per card' },
{ band: 'Individual foil cards', price: '$2.00', note: 'per card' },
{ band: '36 card set', price: '$20.00', note: 'per set of cards' },
{ band: '60 card deck', price: '$30.00', note: 'per set of cards' },
{ band: '100 card deck', price: '$40.00', note: 'per set of cards' },
{ band: 'Deck Disclosure Card', price: '$2.00', 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',
label: 'Deck boxes - Marble, Nebula, and Sparkle Black with Metallic Gold Accents',
},
{
src: '/images/store/deckboxes/ff_front.jpg',
alt: 'Deck Box front view - Fantastic Four',
label: 'Deck Box front view - Fantastic Four - Matte Blue with Metallic Black Accents',
},
{
src: '/images/store/deckboxes/ff_side.jpg',
alt: 'Deck Box side view - Fantastic Four',
label: 'Deck Box side view - Fantastic Four - Matte Blue with Metallic Black Accents',
},
{
src: '/images/store/deckboxes/ff_back.jpg',
alt: 'Deck Box back view - Fantastic Four',
label: 'Deck Box back view - Fantastic Four - Matte Blue with Metallic Black Accents',
},
{
src: '/images/store/deckboxes/ff_open.jpg',
alt: 'Deck Box open view - Fantastic Four',
label: 'Deck Box open view - Fantastic Four - Matte Blue with Metallic Black Accents',
}
],
specs: [
{ label: 'Capacity', value: '100 double-sleeved card bay / 82mm depth and tray 67 mm x 76 mm x 24 mm (width x depth x height)' },
{ label: 'Material', value: 'PLA with 2x4 magnets and top-loader protection pane' },
{ label: 'Finish', value: 'as-printed' },
{ label: 'Sizing', value: '86 mm x 109 mm x 143 mm (width x depth x height)' },
{ label: 'Customization', value: 'Color selection from available filament. Default pages uses Matte Bone White. Default strap uses Matte Light Leather Brown.' },
],
pricing: [
{ band: 'Single deck box', price: '$30.00', note: 'per print' },
{ band: 'Deck box + counter set', price: 'Coming Soon', note: 'per set' },
{ band: 'Counter / token only', price: 'Coming Soon', note: 'per print' },
],
/**
* Available filament colors. Toggle `inStock` to reflect what's loaded on
* the printer; out-of-stock colors show a label and drop out of the order
* form. Add a new { name, hex, inStock } entry to offer a new color.
*/
colors: [
{ name: 'Charcoal Matte Black', hex: '#1d1d1d', inStock: true },
{ name: 'Sparkle Black', hex: '#242424', inStock: true },
{ name: 'Metallic Black', hex: '#242424', inStock: true },
{ name: 'Metallic Gold', hex: '#96793b', inStock: true },
{ name: 'Metallic Blue', hex: '#21356b', inStock: true },
{ name: 'Metallic Silver', hex: '#b1b1b1', inStock: true },
{ name: 'Silver', hex: '#d8d8d8', inStock: true },
{ name: 'Copper', hex: '#61462d', inStock: true },
{ name: 'Orange', hex: '#f77b28', inStock: true },
{ name: 'Shiny Red', hex: '#af0505', inStock: true },
{ name: 'Glossy Red', hex: '#cc2a17', inStock: true },
{ name: 'Scarlet', hex: '#6e140b', inStock: true },
{ name: 'Matte Bone White', hex: '#f4f1e8', inStock: true },
{ name: 'Matte Light Leather Brown', hex: '#bea072', inStock: true },
{ name: 'Basic White', hex: '#ffffff', inStock: true },
{ name: 'Basic Cyan', hex: '#2ba8ce', inStock: true },
{ name: 'Matte Blue', hex: '#365ecc', inStock: true },
{ name: 'Royal Blue', hex: '#2247ac', inStock: true },
{ name: 'Blue Grey', hex: '#46535f', inStock: true },
{ name: 'Purple', hex: '#3a0961', inStock: true },
{ name: 'Marble', hex: '#b9b9b9', inStock: true },
{ name: 'Coffee Brown', hex: '#36241c', inStock: true },
{ name: 'Sunflower Yellow', hex: '#e6e332', inStock: true },
{ name: 'Basic Green', hex: '#24da00', inStock: true },
{ name: 'Matte Leaf Green', hex: '#a6d19d', inStock: true },
{ name: 'Army Green', hex: '#3e611d', inStock: true },
{ name: 'Sparkle Green', hex: '#0b662e', inStock: true },
{ name: 'Sparkle Nebula', hex: '#104568', inStock: true },
],
};
/** 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 offensive art',
body: 'I will not print custom art or any cards I find to be offensive. Only official card art is used unless card art fully provided by the customer.',
},
{
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.',
},
];
+21 -1
View File
@@ -21,9 +21,18 @@ import "@fontsource-variable/jetbrains-mono";
import { ClientRouter } from "astro:transitions";
import NavBar from "@components/nav/NavBar.astro";
import StoreNav from "@components/store/StoreNav.astro";
import Footer from "@components/layout/Footer.astro";
import { themeInitScript } from "@/scripts/theme-init";
import { siteIdentity } from "@lib/site";
// Dev-only react-refresh preamble (see astro.config.mjs for why). Imported as a
// raw string so we can emit it as the FIRST module script in <head>, ahead of
// any React island, so $RefreshReg$/$RefreshSig$ exist before islands eval.
import { REACT_REFRESH_PREAMBLE } from "../../astro.config.mjs";
// Only emit the preamble in dev (import.meta.env.DEV). Production builds compile
// islands to jsx/jsxs and never touch the refresh runtime.
const isDev = import.meta.env.DEV;
type Surface = "neutral" | "professional" | "interest";
@@ -37,6 +46,9 @@ interface Props {
/** When true, emit <meta name="robots" content="noindex, nofollow">. Use on
pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */
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 {
@@ -45,6 +57,7 @@ const {
surface = "neutral",
ogImage,
noindex = false,
storeNav = false,
} = Astro.props;
// True on the Home page. Used to switch the NavBar's view-transition-name so
@@ -80,6 +93,13 @@ const ogImageUrl = ogImage
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
{/* Dev-only react-refresh preamble. MUST be the first module script so the
refresh globals exist before any React island module evaluates. Without
it, islands throw at eval time and never hydrate (see astro.config.mjs). */}
{isDev && (
<script type="module" set:html={REACT_REFRESH_PREAMBLE} />
)}
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
@@ -134,7 +154,7 @@ const ogImageUrl = ogImage
Skip to content
</a>
<NavBar isHome={isHome} />
{storeNav ? <StoreNav /> : <NavBar isHome={isHome} />}
<main id="main">
<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. */
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. */
export const smtpFrom = (): string | undefined => env('SMTP_FROM');
+243
View File
@@ -0,0 +1,243 @@
/**
* /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'),
// Filament color for printed items. Optional (only for deckBoxes/both).
color: z.string().max(120).optional().or(z.literal('')),
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, color, 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, color: color ?? '', 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;
color: 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}`,
`Color: ${msg.color || '(not specified)'}`,
`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,
color: msg.color || undefined,
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>
+163
View File
@@ -0,0 +1,163 @@
---
/**
* /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 ColorGrid from "@components/store/ColorGrid.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>
{/* Available print colors (filament). Managed in store-products.ts. */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Colors"
icon="palette"
title="Available print colors"
/></Reveal
>
<Reveal delay={60}>
<ColorGrid colors={deckBoxProduct.colors} />
</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>
+255
View File
@@ -0,0 +1,255 @@
---
/**
* /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 --- */}
{/* pt-36/sm:pt-40 gives the bright hero content clear breathing room below
the solid fixed StoreNav (64px tall), so the gold label and nav never
visually crowd each other. */}
<section class="relative overflow-hidden pb-8 pt-36 sm:pt-40">
<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 & 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, deckBoxProduct } 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} colors={deckBoxProduct.colors} />
</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/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@lib/*": ["src/lib/*"]
"@lib/*": ["src/lib/*"],
"@data/*": ["src/data/*"]
},
"verbatimModuleSyntax": true,
"allowJs": true,