Merge pull request 'Merge store to master' (#1) from store into master
Reviewed-on: patiun/greg_profile_site#1
@@ -36,6 +36,13 @@ SMTP_ALLOW_SELFSIGNED=
|
|||||||
# Optional webhook fallback (JSON POST). Only used if SMTP_HOST is unset.
|
# Optional webhook fallback (JSON POST). Only used if SMTP_HOST is unset.
|
||||||
# CONTACT_WEBHOOK_URL=
|
# CONTACT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# --- Store order form -> /api/order -----------------------------------------
|
||||||
|
# Reuses the SMTP/relay settings above. ORDER_TO is the inbox that receives
|
||||||
|
# order requests; if blank it falls back to CONTACT_TO. No payment is taken;
|
||||||
|
# the form only collects an order request that is emailed to you. The order
|
||||||
|
# form is hard-gated by src/data/store-availability.json (ordersOpen).
|
||||||
|
ORDER_TO=orders@gregkilmer.com
|
||||||
|
|
||||||
# Honeypot / basic abuse control. Maximum submissions per IP per window.
|
# Honeypot / basic abuse control. Maximum submissions per IP per window.
|
||||||
CONTACT_RATE_LIMIT=5
|
CONTACT_RATE_LIMIT=5
|
||||||
CONTACT_RATE_WINDOW_MIN=15
|
CONTACT_RATE_WINDOW_MIN=15
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Store (store.gregkilmer.com) — MTG Proxy Cards & 3D-Printed Deck Boxes / Counters
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Add a self-contained **store** section to this repo that renders a commerce-style storefront for your proxy-card printing and 3D-printing services, with a gated email-submission order form. It lives at `/store` (and is reachable via `store.gregkilmer.com`), reuses the existing mystic night-sky aesthetic, and shares **no nav links** with the main site. A single JSON file you edit controls order-open/closed, per-category slot counts, and turnaround messaging.
|
||||||
|
|
||||||
|
**Key constraint honored:** no payment is taken; the order form is **hard-locked** until you flip a flag in the JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture decision (defaulting, since the question went unanswered)
|
||||||
|
- **Same app, `/store` route set, with a store-only nav and zero cross-links from the main site.** Store pages are reachable only by direct URL or the `store.gregkilmer.com` subdomain (the subdomain routing is a reverse-proxy concern, not code). This reuses BaseLayout, the theme system, Icon, Reveal, SectionHeader, the contact/email pipeline, and Turnstile — no new dependencies.
|
||||||
|
- **Order lock = hard gate.** When `ordersOpen` is false the order page shows a clear "orders currently closed" notice, links to your email, and **renders no form at all**. When true, the full order form appears.
|
||||||
|
- **Capacity JSON drives:** overall store open/closed flag, open-slot counts for cards and for 3D-print jobs (shown as "X of Y open"), an estimated turnaround string, and a last-updated timestamp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to create
|
||||||
|
|
||||||
|
### 1. Store data + availability (the "simple JSON file you edit")
|
||||||
|
- **`src/data/store-availability.json`** — the single file you edit to control the storefront. Schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ordersOpen": false, // master gate — false = form hidden
|
||||||
|
"lastUpdated": "2026-08-04",
|
||||||
|
"turnaround": "Estimated turnaround: ~2 weeks once a list is confirmed.",
|
||||||
|
"maxNote": "I take a limited number of orders at a time so each gets full attention.",
|
||||||
|
"slots": {
|
||||||
|
"cards": { "open": 0, "total": 5 },
|
||||||
|
"deckBoxes": { "open": 0, "total": 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`src/data/store-products.ts`** — typed product/pricing data (cards, deck boxes, finishes, sizing), authored in TS for type-safety and kept here (not JSON) so it's version-controlled with the components that render it. Pricing tables and product specs read from here.
|
||||||
|
|
||||||
|
### 2. Store-only layout + nav
|
||||||
|
- **`src/layouts/StoreLayout.astro`** — wraps `BaseLayout` with `surface="interest"` (warm candlelit tone, clearly distinct from the dark professional wing but still on-brand). Passes a `noindex` option (store can be noindexed until you're ready to promote). Renders a **store-specific top nav** (`/store`, `/store/cards`, `/store/deck-boxes`, `/store/order`) with a store wordmark and a small "store.gregkilmer.com" label, and a store footer variant that points to your email — **no links back to the main site**, per your request.
|
||||||
|
- **`src/components/store/StoreNav.astro`** — the store nav bar (mirrors `NavBar.astro`'s scroll-blur treatment and StarBurst mark, but store items only).
|
||||||
|
|
||||||
|
### 3. Reusable store UI components
|
||||||
|
- **`src/components/store/ProductCard.astro`** — image + spec list (size, thickness, finish, white back, etc.) for a product.
|
||||||
|
- **`src/components/store/PricingTable.astro`** — generic pricing-by-quantity table (takes rows of `{ quantity, price, note? }`).
|
||||||
|
- **`src/components/store/SlotMeter.astro`** — visual "X of Y slots open" meter with open/closed pill.
|
||||||
|
- **`src/components/store/RequirementList.astro`** — the rules/requirements list with mdi check/alert icons.
|
||||||
|
|
||||||
|
### 4. Pages
|
||||||
|
- **`src/pages/store/index.astro`** — Storefront home: hero (uses existing `/images/proxies.jpg` / `/images/deckboxes.jpg`), intro, **clear disclaimer block** (not affiliated with WotC/any game company; these are clearly-marked proxies, not counterfeits; will not print offensive content or custom art), capacity/availability summary from the JSON, links to the two product lines + order page.
|
||||||
|
- **`src/pages/store/cards.astro`** — Proxy MTG cards product line: example images, spec details (size = standard MTG 63×88mm, cardstock thickness/finish options, white back, etc.), pricing table for sets (e.g. 1–9 / 10–39 / 40–99 / 100+ tiers), double-sided-counts-as-two rule callout.
|
||||||
|
- **`src/pages/store/deck-boxes.astro`** — 3D-printed deck box & counter product line: example images (`/images/deckboxes.jpg`), sizing/finish/material details, pricing table for prints.
|
||||||
|
- **`src/pages/store/order.astro`** — Order page. Reads `store-availability.json`; when `ordersOpen === false`, renders the **closed notice + email link, no form**. When open, renders the order form island and shows the slot meters + requirements inline.
|
||||||
|
|
||||||
|
### 5. Order form + API
|
||||||
|
- **`src/components/store/OrderForm.tsx`** — React island modeled on the existing `ContactForm.tsx` (same idle/submitting/success/error states, honeypot, optional Turnstile, aria-live, same input styling). Fields: name, email, order type (cards / deck-box print / both), card list / deck-box spec (textarea), quantity/finish selectors, notes, honeypot, Turnstile token. **Posts to `/api/order`.**
|
||||||
|
- **`src/pages/api/order.ts`** — new POST endpoint cloned from `/api/contact.ts`'s proven pipeline: Zod validation, per-IP rate limit, Turnstile verification (reusing `@lib/env`), SMTP-or-webhook delivery to your inbox with a subject like `New store order — ${type}`. **Server-side hard gate:** even if a client POSTs while `ordersOpen` is false, the endpoint reads the JSON and returns 403 "Orders are currently closed." This makes the lock enforced both in UI and on the server.
|
||||||
|
|
||||||
|
### 6. Env additions
|
||||||
|
- Append to **`.env.example`** (and document in the new docs file): an optional `ORDER_TO` inbox (defaults to `CONTACT_TO`), and reuse existing `TURNSTILE_*`, `SMTP_*`, and rate-limit vars — **no new dependencies**. Order emails reuse the same `deliver()` pattern.
|
||||||
|
|
||||||
|
### 7. Docs
|
||||||
|
- **`docs/STORE.md`** — how to edit `store-availability.json` to open/close orders and update slots/turnaround, how the hard gate works (UI + API), how to point `store.gregkilmer.com` at the same server via your reverse proxy, and the "no cross-links" rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files modified
|
||||||
|
- **`src/lib/env.ts`** — add `orderTo()` helper (falls back to `contactTo()`).
|
||||||
|
- **`.env.example`** — add `ORDER_TO=` with a comment.
|
||||||
|
- **`src/middleware.ts`** — no change needed; CSP already allows `'self'` form-action, Turnstile origins, and same-origin connect. (I'll add a comment noting `/api/order` reuses the existing policy.)
|
||||||
|
- No change to the main site nav (`site.ts`), Footer, or any main-site page — **zero cross-links**, as requested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How each of your requirements is met
|
||||||
|
| Requirement | Where |
|
||||||
|
|---|---|
|
||||||
|
| Order form submits an email | `OrderForm.tsx` → `/api/order` → SMTP/webhook (cloned from working `/api/contact`) |
|
||||||
|
| Example images + product details | `cards.astro` / `deck_boxes.astro` + `ProductCard.astro` (size, thickness, finishes, white back, etc.) |
|
||||||
|
| Pricing tables for card sets & deck-box prints | `PricingTable.astro` fed by `store-products.ts` |
|
||||||
|
| Hosted at store.gregkilmer.com, no clear link between sites | `/store` route set, store-only nav, no main-site links; subdomain is a reverse-proxy mapping to the same server |
|
||||||
|
| No payment taken | Form collects a *request* only; no payment fields, no checkout |
|
||||||
|
| Order form locked until you choose to accept | Hard gate: `ordersOpen` in JSON hides the form (UI) **and** 403s the API (server) |
|
||||||
|
| Number of card + 3D-print slots, updatable from a simple JSON | `slots.cards` / `slots.deckBoxes` in `store-availability.json`, rendered by `SlotMeter.astro` |
|
||||||
|
| Clear "I can only take so many at a time" | `maxNote` + slot meters prominently on storefront and order page |
|
||||||
|
| Stated requirements (card list final, double-sided = 2 slots, payment is final, not affiliated with WotC, no counterfeits — clearly proxies, no offensive/custom art, etc.) | `RequirementList.astro` on storefront + repeated on the order page; order form requires an explicit acknowledgment checkbox |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defaults I'll use (flag any you want changed)
|
||||||
|
- **Disclaimer copy** drafted to match your wording (not affiliated with WotC / any game company; clearly-marked proxies, never counterfeits; no offensive or unauthorized custom art; card list is final once received; double-sided = two cards/two slots; all sales final). You can tweak exact phrasing after.
|
||||||
|
- **Pricing & specs** are placeholder structure with clearly-marked `[Greg to fill]` values (you'll swap in your real tier prices, cardstock GSM, finish names, deck-box dimensions). I won't invent real prices.
|
||||||
|
- **Slot counts** default to `{ cards: 0/5, deckBoxes: 0/4 }` and `ordersOpen: false`, so the site launches visibly "closed."
|
||||||
|
- **`noindex`** on by default for store pages until you say otherwise.
|
||||||
|
|
||||||
|
I will not commit or push anything. Ready to implement on approval.
|
||||||
@@ -28,6 +28,27 @@ const SITE_URL = process.env.SITE_URL ?? 'https://gregkilmer.com';
|
|||||||
const PORT = Number(process.env.PORT ?? 4321);
|
const PORT = Number(process.env.PORT ?? 4321);
|
||||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
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({
|
export default defineConfig({
|
||||||
// Used for canonical URLs, sitemaps, and absolute metadata. Keep this in sync with the
|
// Used for canonical URLs, sitemaps, and absolute metadata. Keep this in sync with the
|
||||||
// deployed domain on your Cosmos reverse proxy.
|
// deployed domain on your Cosmos reverse proxy.
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"@astrojs/check": "^0.9.9",
|
"@astrojs/check": "^0.9.9",
|
||||||
"@astrojs/mdx": "^7.0.2",
|
"@astrojs/mdx": "^7.0.2",
|
||||||
"@astrojs/node": "^11.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/inter": "^5.2.8",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"astro": "^7.0.6",
|
"astro": "^7.2.0",
|
||||||
"framer-motion": "^12.42.2",
|
"framer-motion": "^12.42.2",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
|
|||||||
@@ -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 · SWAP IN /images/store-products.ts</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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 · SWAP IN /images/store-products.ts</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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 · SWAP IN /images/store-products.ts</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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 · SWAP IN /images/store-products.ts</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -38,6 +38,13 @@ const ICON_PATHS: Record<string, string> = {
|
|||||||
'M6 3h5v5H6V3zm7 0h5v5h-5V3zM3 11h5v5H3v-5zm7 0h5v5h-5v-5zm7 0h-5v5h5v-5z',
|
'M6 3h5v5H6V3zm7 0h5v5h-5V3zM3 11h5v5H3v-5zm7 0h5v5h-5v-5zm7 0h-5v5h5v-5z',
|
||||||
email:
|
email:
|
||||||
'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm8 7L4 6v1l8 5 8-5V6l-8 5z',
|
'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm8 7L4 6v1l8 5 8-5V6l-8 5z',
|
||||||
|
// Store nav icons (kept here so the store mobile menu is self-contained).
|
||||||
|
store: 'M4 7h16l-1 13H5L4 7zm5-3a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0 3 3',
|
||||||
|
'cards-playing': 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm3 4v3m6 7v-3',
|
||||||
|
'printer-3d-nozzle':
|
||||||
|
'M7 2h10v4l3 3-4 2H8L4 9l3-3V2zm5 11v9',
|
||||||
|
'clipboard-list':
|
||||||
|
'M9 3h6v2H9V3zm-2 0v2H6a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-1V3M7 11h2m4 0h4M7 15h2m4 0h4',
|
||||||
};
|
};
|
||||||
|
|
||||||
function Icon({ name, className }: { name: string; className?: string }) {
|
function Icon({ name, className }: { name: string; className?: string }) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
@@ -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…</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’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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,9 +21,18 @@ import "@fontsource-variable/jetbrains-mono";
|
|||||||
|
|
||||||
import { ClientRouter } from "astro:transitions";
|
import { ClientRouter } from "astro:transitions";
|
||||||
import NavBar from "@components/nav/NavBar.astro";
|
import NavBar from "@components/nav/NavBar.astro";
|
||||||
|
import StoreNav from "@components/store/StoreNav.astro";
|
||||||
import Footer from "@components/layout/Footer.astro";
|
import Footer from "@components/layout/Footer.astro";
|
||||||
import { themeInitScript } from "@/scripts/theme-init";
|
import { themeInitScript } from "@/scripts/theme-init";
|
||||||
import { siteIdentity } from "@lib/site";
|
import { siteIdentity } from "@lib/site";
|
||||||
|
// 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";
|
type Surface = "neutral" | "professional" | "interest";
|
||||||
|
|
||||||
@@ -37,6 +46,9 @@ interface Props {
|
|||||||
/** When true, emit <meta name="robots" content="noindex, nofollow">. Use on
|
/** When true, emit <meta name="robots" content="noindex, nofollow">. Use on
|
||||||
pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */
|
pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */
|
||||||
noindex?: boolean;
|
noindex?: boolean;
|
||||||
|
/** When true, render the store-only nav (StoreNav) instead of the main NavBar.
|
||||||
|
Used by store pages, which intentionally share no links with the main site. */
|
||||||
|
storeNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -45,6 +57,7 @@ const {
|
|||||||
surface = "neutral",
|
surface = "neutral",
|
||||||
ogImage,
|
ogImage,
|
||||||
noindex = false,
|
noindex = false,
|
||||||
|
storeNav = false,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
// True on the Home page. Used to switch the NavBar's view-transition-name so
|
// True on the Home page. Used to switch the NavBar's view-transition-name so
|
||||||
@@ -80,6 +93,13 @@ const ogImageUrl = ogImage
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<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>
|
<title>{fullTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<link rel="canonical" href={canonical} />
|
<link rel="canonical" href={canonical} />
|
||||||
@@ -134,7 +154,7 @@ const ogImageUrl = ogImage
|
|||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<NavBar isHome={isHome} />
|
{storeNav ? <StoreNav /> : <NavBar isHome={isHome} />}
|
||||||
|
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -79,6 +79,12 @@ export const smtpPass = (): string | undefined => env('SMTP_PASS');
|
|||||||
/** Inbox that receives contact form submissions. */
|
/** Inbox that receives contact form submissions. */
|
||||||
export const contactTo = (): string | undefined => env('CONTACT_TO');
|
export const contactTo = (): string | undefined => env('CONTACT_TO');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbox that receives store order submissions. Falls back to CONTACT_TO so the
|
||||||
|
* store works without a separate inbox configured. Used by /api/order.
|
||||||
|
*/
|
||||||
|
export const orderTo = (): string | undefined => env('ORDER_TO') ?? contactTo();
|
||||||
|
|
||||||
/** From address for outgoing mail. */
|
/** From address for outgoing mail. */
|
||||||
export const smtpFrom = (): string | undefined => env('SMTP_FROM');
|
export const smtpFrom = (): string | undefined => env('SMTP_FROM');
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 & 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 & 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>
|
||||||
@@ -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 & 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 — 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 & requirements (for when orders open)" />
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StoreLayout>
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"@/*": ["src/*"],
|
"@/*": ["src/*"],
|
||||||
"@components/*": ["src/components/*"],
|
"@components/*": ["src/components/*"],
|
||||||
"@layouts/*": ["src/layouts/*"],
|
"@layouts/*": ["src/layouts/*"],
|
||||||
"@lib/*": ["src/lib/*"]
|
"@lib/*": ["src/lib/*"],
|
||||||
|
"@data/*": ["src/data/*"]
|
||||||
},
|
},
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|||||||