Images and content updates
@@ -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.
|
||||||
|
|||||||
@@ -68,6 +68,28 @@ Each product has an `icon`, `title`, `summary`, example `image`, a `specs` list
|
|||||||
(size, thickness, finishes, white back, etc.), and `pricing` tiers. Search for
|
(size, thickness, finishes, white back, etc.), and `pricing` tiers. Search for
|
||||||
`[Greg to fill]` to find the placeholder prices and concrete specs to replace.
|
`[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`)
|
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
|
and is rendered on the storefront, the order page, and required as a checkbox on
|
||||||
the order form.
|
the order form.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
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 |
@@ -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>
|
||||||
|
)}
|
||||||
@@ -38,15 +38,34 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ColorOption {
|
||||||
|
name: string;
|
||||||
|
hex: string;
|
||||||
|
inStock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
|
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
|
||||||
turnstileSiteKey?: string;
|
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 }: Props) {
|
export default function OrderForm({ turnstileSiteKey, colors = [] }: Props) {
|
||||||
const [status, setStatus] = useState<Status>('idle');
|
const [status, setStatus] = useState<Status>('idle');
|
||||||
const [errors, setErrors] = useState<FieldErrors>({});
|
const [errors, setErrors] = useState<FieldErrors>({});
|
||||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
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 turnstileTokenRef = useRef<string>('');
|
||||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||||
@@ -97,6 +116,7 @@ export default function OrderForm({ turnstileSiteKey }: Props) {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
orderType: OrderType;
|
orderType: OrderType;
|
||||||
|
color: string;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
details: string;
|
details: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
@@ -125,6 +145,7 @@ export default function OrderForm({ turnstileSiteKey }: Props) {
|
|||||||
name: String(data.get('name') ?? '').trim(),
|
name: String(data.get('name') ?? '').trim(),
|
||||||
email: String(data.get('email') ?? '').trim(),
|
email: String(data.get('email') ?? '').trim(),
|
||||||
orderType: (String(data.get('orderType') ?? 'cards') as OrderType),
|
orderType: (String(data.get('orderType') ?? 'cards') as OrderType),
|
||||||
|
color: String(data.get('color') ?? '').trim(),
|
||||||
quantity: String(data.get('quantity') ?? '').trim(),
|
quantity: String(data.get('quantity') ?? '').trim(),
|
||||||
details: String(data.get('details') ?? '').trim(),
|
details: String(data.get('details') ?? '').trim(),
|
||||||
notes: String(data.get('notes') ?? '').trim(),
|
notes: String(data.get('notes') ?? '').trim(),
|
||||||
@@ -224,13 +245,37 @@ export default function OrderForm({ turnstileSiteKey }: Props) {
|
|||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<label htmlFor="orderType" className={labelBase} style={fieldColor}>Order type</label>
|
<label htmlFor="orderType" className={labelBase} style={fieldColor}>Order type</label>
|
||||||
<select id="orderType" name="orderType" defaultValue="cards" className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor }}>
|
<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="cards">Proxy MTG cards</option>
|
||||||
<option value="deckBoxes">3D-printed deck box / counters</option>
|
<option value="deckBoxes">3D-printed deck box / counters</option>
|
||||||
<option value="both">Both</option>
|
<option value="both">Both</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col">
|
||||||
<label htmlFor="quantity" className={labelBase} style={fieldColor}>Approximate quantity</label>
|
<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} />
|
<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} />
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
"maxNote": "I take a limited number of orders at a time so each one gets full attention. Slots reopen as current orders finish.",
|
"maxNote": "I take a limited number of orders at a time so each one gets full attention. Slots reopen as current orders finish.",
|
||||||
"slots": {
|
"slots": {
|
||||||
"cards": {
|
"cards": {
|
||||||
"open": 0,
|
"open": 1,
|
||||||
"total": 3
|
"total": 3
|
||||||
},
|
},
|
||||||
"deckBoxes": {
|
"deckBoxes": {
|
||||||
"open": 0,
|
"open": 2,
|
||||||
"total": 3
|
"total": 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,34 +100,48 @@ export const cardProduct: Product = {
|
|||||||
slotKey: 'cards',
|
slotKey: 'cards',
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
src: '/images/proxies.jpg',
|
src: '/images/store/cards/proxies.jpg',
|
||||||
alt: 'Example printed proxy MTG cards, front faces',
|
alt: 'Example printed proxy MTG cards, front faces',
|
||||||
label: 'Card fronts \u2014 full-color print',
|
label: 'Card fronts \u2014 full-color print',
|
||||||
},
|
},{
|
||||||
{
|
src: '/images/store/cards/proxies2.jpg',
|
||||||
src: '/images/proxies-example-1.svg',
|
alt: 'Example printed foil proxy MTG cards, front faces',
|
||||||
alt: 'Placeholder: close-up of card finish and cut',
|
label: 'Card fronts \u2014 foil print',
|
||||||
label: 'Placeholder \u2014 close-up of finish & cut',
|
},{
|
||||||
},
|
src: '/images/store/cards/foil_cloud_measured.jpg',
|
||||||
{
|
alt: 'Example printed foil cloud proxy card with measurements',
|
||||||
src: '/images/proxies-example-2.svg',
|
label: 'Rainbow foil card front with measurements',
|
||||||
alt: 'Placeholder: white card back showing the proxy marking',
|
},{
|
||||||
label: 'Placeholder \u2014 solid white back',
|
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: [
|
specs: [
|
||||||
{ label: 'Size', value: '63 x 88 mm (standard MTG card size)' },
|
{ label: 'Size', value: '63 x 88 mm (standard MTG card size)' },
|
||||||
{ label: 'Thickness', value: '0.31mm laminated photo cardstock' },
|
{ label: 'Thickness', value: '0.37mm laminated photo cardstock' },
|
||||||
{ label: 'Back', value: 'Solid white back (clearly not tournament-legal)' },
|
{ label: 'Back', value: 'Solid white back (clearly not tournament-legal)' },
|
||||||
{
|
{
|
||||||
label: 'Finishes',
|
label: 'Finishes',
|
||||||
value: 'Default matte finish, available in glossy on request',
|
value: 'Default matte finish, available in glossy on request (Foil cards are always glossy finish)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Double-sided cards',
|
label: 'Double-sided cards',
|
||||||
value: 'Printed as two separate cards and count as 2 card slots',
|
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 see Requirements)' },
|
{ label: 'Art', value: 'Official card art only. No offensive art (custom cards usable if fully provided)' },
|
||||||
],
|
],
|
||||||
pricing: [
|
pricing: [
|
||||||
{ band: 'Individual cards', price: '$1.00', note: 'per card' },
|
{ band: 'Individual cards', price: '$1.00', note: 'per card' },
|
||||||
@@ -151,32 +165,77 @@ export const deckBoxProduct: Product = {
|
|||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
src: '/images/deckboxes.jpg',
|
src: '/images/deckboxes.jpg',
|
||||||
alt: 'Example 3D-printed deck boxes and counters',
|
alt: 'Example 3D-printed deck boxes',
|
||||||
label: 'Deck box & counters \u2014 assembled',
|
label: 'Deck boxes - Marble, Nebula, and Sparkle Black with Metallic Gold Accents',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/images/deckboxes-example-1.svg',
|
src: '/images/store/deckboxes/ff_front.jpg',
|
||||||
alt: 'Placeholder: deck box open showing card capacity',
|
alt: 'Deck Box front view - Fantastic Four',
|
||||||
label: 'Placeholder \u2014 open, showing capacity',
|
label: 'Deck Box front view - Fantastic Four - Matte Blue with Metallic Black Accents',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/images/deckboxes-example-2.svg',
|
src: '/images/store/deckboxes/ff_side.jpg',
|
||||||
alt: 'Placeholder: counter close-up with life totals',
|
alt: 'Deck Box side view - Fantastic Four',
|
||||||
label: 'Placeholder \u2014 counter detail',
|
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: [
|
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: '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: 'Material', value: 'PLA with 2x4 magnets and top-loader protection pane' },
|
||||||
{ label: 'Finish', value: 'as-printed' },
|
{ label: 'Finish', value: 'as-printed' },
|
||||||
{ label: 'Sizing', value: '86 mm x 109 mm x 143 mm (width x depth x height)' },
|
{ label: 'Sizing', value: '86 mm x 109 mm x 143 mm (width x depth x height)' },
|
||||||
{ label: 'Customization', value: 'Color selection from available filament' },
|
{ label: 'Customization', value: 'Color selection from available filament. Default pages uses Matte Bone White. Default strap uses Matte Light Leather Brown.' },
|
||||||
],
|
],
|
||||||
pricing: [
|
pricing: [
|
||||||
{ band: 'Single deck box', price: '$30.00', note: 'per print' },
|
{ band: 'Single deck box', price: '$30.00', note: 'per print' },
|
||||||
{ band: 'Deck box + counter set', price: 'Coming Soon', note: 'per set' },
|
{ band: 'Deck box + counter set', price: 'Coming Soon', note: 'per set' },
|
||||||
{ band: 'Counter / token only', price: 'Coming Soon', note: 'per print' },
|
{ 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. */
|
/** Both product lines, for storefront iteration. */
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ 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";
|
||||||
|
|
||||||
@@ -85,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} />
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const schema = z.object({
|
|||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
email: z.string().email().max(200),
|
email: z.string().email().max(200),
|
||||||
orderType: orderTypeEnum.default('cards'),
|
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),
|
quantity: z.string().min(1).max(200),
|
||||||
details: z.string().min(10).max(20000),
|
details: z.string().min(10).max(20000),
|
||||||
notes: z.string().max(5000).optional().or(z.literal('')),
|
notes: z.string().max(5000).optional().or(z.literal('')),
|
||||||
@@ -131,7 +133,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return json({ error: 'Please complete all fields correctly, including the policy acknowledgment.', issues: parsed.error.issues }, 400);
|
return json({ error: 'Please complete all fields correctly, including the policy acknowledgment.', issues: parsed.error.issues }, 400);
|
||||||
}
|
}
|
||||||
const { name, email, orderType, quantity, details, notes, turnstileToken } = parsed.data;
|
const { name, email, orderType, color, quantity, details, notes, turnstileToken } = parsed.data;
|
||||||
|
|
||||||
// Rate limit.
|
// Rate limit.
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
@@ -147,7 +149,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
// Deliver.
|
// Deliver.
|
||||||
try {
|
try {
|
||||||
const delivered = await deliver({ name, email, orderType, quantity, details, notes: notes ?? '' });
|
const delivered = await deliver({ name, email, orderType, color: color ?? '', quantity, details, notes: notes ?? '' });
|
||||||
if (!delivered) {
|
if (!delivered) {
|
||||||
return json({ error: 'Order delivery is not configured yet. Please email me directly.' }, 503);
|
return json({ error: 'Order delivery is not configured yet. Please email me directly.' }, 503);
|
||||||
}
|
}
|
||||||
@@ -163,6 +165,7 @@ interface OrderMessage {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
orderType: string;
|
orderType: string;
|
||||||
|
color: string;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
details: string;
|
details: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
@@ -180,6 +183,7 @@ async function deliver(msg: OrderMessage): Promise<boolean> {
|
|||||||
`Name: ${msg.name}`,
|
`Name: ${msg.name}`,
|
||||||
`Email: ${msg.email}`,
|
`Email: ${msg.email}`,
|
||||||
`Order type: ${typeLabel}`,
|
`Order type: ${typeLabel}`,
|
||||||
|
`Color: ${msg.color || '(not specified)'}`,
|
||||||
`Approx quantity: ${msg.quantity}`,
|
`Approx quantity: ${msg.quantity}`,
|
||||||
``,
|
``,
|
||||||
`Details:`,
|
`Details:`,
|
||||||
@@ -226,6 +230,7 @@ async function deliver(msg: OrderMessage): Promise<boolean> {
|
|||||||
name: msg.name,
|
name: msg.name,
|
||||||
email: msg.email,
|
email: msg.email,
|
||||||
orderType: typeLabel,
|
orderType: typeLabel,
|
||||||
|
color: msg.color || undefined,
|
||||||
quantity: msg.quantity,
|
quantity: msg.quantity,
|
||||||
details: msg.details,
|
details: msg.details,
|
||||||
notes: msg.notes,
|
notes: msg.notes,
|
||||||
|
|||||||
@@ -9,24 +9,26 @@
|
|||||||
* (deckBoxProduct.images). The specs block is image-free now; examples live in
|
* (deckBoxProduct.images). The specs block is image-free now; examples live in
|
||||||
* the carousel only.
|
* the carousel only.
|
||||||
*/
|
*/
|
||||||
import StoreLayout from '@layouts/StoreLayout.astro';
|
import StoreLayout from "@layouts/StoreLayout.astro";
|
||||||
import Reveal from '@components/motion/Reveal.astro';
|
import Reveal from "@components/motion/Reveal.astro";
|
||||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||||
import Icon from '@components/Icon.astro';
|
import Icon from "@components/Icon.astro";
|
||||||
import Carousel from '@components/store/Carousel.tsx';
|
import Carousel from "@components/store/Carousel.tsx";
|
||||||
import ProductCard from '@components/store/ProductCard.astro';
|
import ProductCard from "@components/store/ProductCard.astro";
|
||||||
import PricingTable from '@components/store/PricingTable.astro';
|
import PricingTable from "@components/store/PricingTable.astro";
|
||||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
import SlotMeter from "@components/store/SlotMeter.astro";
|
||||||
import RequirementList from '@components/store/RequirementList.astro';
|
import ColorGrid from "@components/store/ColorGrid.astro";
|
||||||
import { deckBoxProduct, requirements } from '@data/store-products';
|
import RequirementList from "@components/store/RequirementList.astro";
|
||||||
import { storeAvailability } from '@data/store-availability';
|
import { deckBoxProduct, requirements } from "@data/store-products";
|
||||||
|
import { storeAvailability } from "@data/store-availability";
|
||||||
|
|
||||||
const av = storeAvailability;
|
const av = storeAvailability;
|
||||||
const slot = av.slots[deckBoxProduct.slotKey];
|
const slot = av.slots[deckBoxProduct.slotKey];
|
||||||
const keyReqs = requirements.filter((r) =>
|
const keyReqs = requirements.filter(
|
||||||
r.title.toLowerCase().includes('final')
|
(r) =>
|
||||||
|| r.title.toLowerCase().includes('capacity')
|
r.title.toLowerCase().includes("final") ||
|
||||||
|| r.title.toLowerCase().includes('affiliation'),
|
r.title.toLowerCase().includes("capacity") ||
|
||||||
|
r.title.toLowerCase().includes("affiliation"),
|
||||||
);
|
);
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,28 +50,54 @@ const keyReqs = requirements.filter((r) =>
|
|||||||
</a>
|
</a>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<SectionHeader kicker="Product" icon={deckBoxProduct.icon} title={deckBoxProduct.title} subtitle={deckBoxProduct.summary} />
|
<SectionHeader
|
||||||
|
kicker="Product"
|
||||||
|
icon={deckBoxProduct.icon}
|
||||||
|
title={deckBoxProduct.title}
|
||||||
|
subtitle={deckBoxProduct.summary}
|
||||||
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Live slot tracker for this product line (top of content). */}
|
{/* Live slot tracker for this product line (top of content). */}
|
||||||
<section class="flex flex-col gap-3">
|
<section class="flex flex-col gap-3">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SlotMeter label="3D-print slots" icon="printer-3d-nozzle" open={slot.open} total={slot.total} />
|
<SlotMeter
|
||||||
|
label="3D-print slots"
|
||||||
|
icon="printer-3d-nozzle"
|
||||||
|
open={slot.open}
|
||||||
|
total={slot.total}
|
||||||
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Image carousel (server-driven, swipeable). */}
|
{/* Image carousel (server-driven, swipeable). */}
|
||||||
<section class="flex flex-col gap-4">
|
<section class="flex flex-col gap-4">
|
||||||
<Reveal><SectionHeader kicker="Examples" icon="image-multiple" title={`See the ${deckBoxProduct.title.toLowerCase()}`} /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Examples"
|
||||||
|
icon="image-multiple"
|
||||||
|
title={`See the ${deckBoxProduct.title.toLowerCase()}`}
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<Carousel client:visible slides={deckBoxProduct.images} label={`${deckBoxProduct.title} examples`} />
|
<Carousel
|
||||||
|
client:visible
|
||||||
|
slides={deckBoxProduct.images}
|
||||||
|
label={`${deckBoxProduct.title} examples`}
|
||||||
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Specifications (no image — examples live in the carousel). */}
|
{/* Specifications (no image — examples live in the carousel). */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal><SectionHeader kicker="Details" icon="ruler" title="Specifications" /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Details"
|
||||||
|
icon="ruler"
|
||||||
|
title="Specifications"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<ProductCard product={deckBoxProduct} detailed />
|
<ProductCard product={deckBoxProduct} detailed />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -77,7 +105,13 @@ const keyReqs = requirements.filter((r) =>
|
|||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal><SectionHeader kicker="Pricing" icon="currency-usd" title="Prints" /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Pricing"
|
||||||
|
icon="currency-usd"
|
||||||
|
title="Prints"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<PricingTable
|
<PricingTable
|
||||||
icon="printer-3d-nozzle"
|
icon="printer-3d-nozzle"
|
||||||
@@ -88,9 +122,29 @@ const keyReqs = requirements.filter((r) =>
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</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 */}
|
{/* Key requirements */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal><SectionHeader kicker="Before you order" icon="clipboard-check" title="Key requirements" /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Before you order"
|
||||||
|
icon="clipboard-check"
|
||||||
|
title="Key requirements"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<RequirementList items={keyReqs} />
|
<RequirementList items={keyReqs} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -101,7 +155,7 @@ const keyReqs = requirements.filter((r) =>
|
|||||||
<div class="flex flex-col items-center gap-4 text-center">
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
<a href="/store/order" class="btn btn-primary">
|
<a href="/store/order" class="btn btn-primary">
|
||||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||||
{av.ordersOpen ? 'Start an order request' : 'View order page'}
|
{av.ordersOpen ? "Start an order request" : "View order page"}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -10,30 +10,34 @@
|
|||||||
* but is rendered through StoreLayout so it gets the store-only nav and
|
* but is rendered through StoreLayout so it gets the store-only nav and
|
||||||
* noindex by default. No links back to the main site.
|
* noindex by default. No links back to the main site.
|
||||||
*/
|
*/
|
||||||
import StoreLayout from '@layouts/StoreLayout.astro';
|
import StoreLayout from "@layouts/StoreLayout.astro";
|
||||||
import Reveal from '@components/motion/Reveal.astro';
|
import Reveal from "@components/motion/Reveal.astro";
|
||||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||||
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
|
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
|
||||||
import StarBurst from '@components/mystic/StarBurst.astro';
|
import StarBurst from "@components/mystic/StarBurst.astro";
|
||||||
import Icon from '@components/Icon.astro';
|
import Icon from "@components/Icon.astro";
|
||||||
import ProductTile from '@components/store/ProductTile.astro';
|
import ProductTile from "@components/store/ProductTile.astro";
|
||||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
import SlotMeter from "@components/store/SlotMeter.astro";
|
||||||
import RequirementList from '@components/store/RequirementList.astro';
|
import RequirementList from "@components/store/RequirementList.astro";
|
||||||
import { cardProduct, deckBoxProduct, requirements } from '@data/store-products';
|
import {
|
||||||
import { storeAvailability } from '@data/store-availability';
|
cardProduct,
|
||||||
|
deckBoxProduct,
|
||||||
|
requirements,
|
||||||
|
} from "@data/store-products";
|
||||||
|
import { storeAvailability } from "@data/store-availability";
|
||||||
|
|
||||||
const av = storeAvailability;
|
const av = storeAvailability;
|
||||||
const orderHref = '/store/order';
|
const orderHref = "/store/order";
|
||||||
---
|
---
|
||||||
|
|
||||||
<StoreLayout
|
<StoreLayout title="Store — Proxy MTG Cards & 3D-Printed Deck Boxes">
|
||||||
title="Store — Proxy MTG Cards & 3D-Printed Deck Boxes"
|
|
||||||
>
|
|
||||||
{/* --- Hero --- */}
|
{/* --- Hero --- */}
|
||||||
<section class="relative overflow-hidden pb-8 pt-28 sm:pt-32">
|
<section class="relative overflow-hidden pb-8 pt-28 sm:pt-32">
|
||||||
<AnimatedBackground client:idle className="vt-sky" />
|
<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="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">
|
<div class="flex flex-col items-start gap-4">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<span
|
<span
|
||||||
@@ -69,8 +73,8 @@ const orderHref = '/store/order';
|
|||||||
class="max-w-xl border-l-2 pl-4 font-sans text-base italic sm:text-lg"
|
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;"
|
style="border-color: var(--color-accent); color: var(--color-cream); opacity: 0.92;"
|
||||||
>
|
>
|
||||||
Clearly-marked proxies for casual play, and matching 3D-printed storage &
|
Clearly-marked proxies for casual play, and matching 3D-printed
|
||||||
counters. Hand-made in small batches.
|
storage & counters. Hand-made in small batches.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -107,10 +111,17 @@ const orderHref = '/store/order';
|
|||||||
{/* --- Intro + limited capacity banner --- */}
|
{/* --- Intro + limited capacity banner --- */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeader kicker="Welcome" icon="store" title="A small workshop, not a factory" />
|
<SectionHeader
|
||||||
|
kicker="Welcome"
|
||||||
|
icon="store"
|
||||||
|
title="A small workshop, not a factory"
|
||||||
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<p class="max-w-2xl text-lg leading-relaxed" style="color: var(--color-text-muted);">
|
<p
|
||||||
|
class="max-w-2xl text-lg leading-relaxed"
|
||||||
|
style="color: var(--color-text-muted);"
|
||||||
|
>
|
||||||
{av.maxNote}
|
{av.maxNote}
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -120,11 +131,22 @@ const orderHref = '/store/order';
|
|||||||
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
|
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">
|
<div class="flex items-center gap-3">
|
||||||
<Icon name="clock-outline" class="h-6 w-6 shrink-0" style="color: var(--color-accent);" />
|
<Icon
|
||||||
<span class="text-sm sm:text-base" style="color: var(--color-text);">{av.turnaround}</span>
|
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>
|
</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);'}>
|
<span
|
||||||
{av.ordersOpen ? 'Orders open' : 'Orders currently closed'}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -133,7 +155,11 @@ const orderHref = '/store/order';
|
|||||||
{/* --- Product lines --- */}
|
{/* --- Product lines --- */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeader kicker="Products" icon="cards-outline" title="What I make" />
|
<SectionHeader
|
||||||
|
kicker="Products"
|
||||||
|
icon="cards-outline"
|
||||||
|
title="What I make"
|
||||||
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<div class="grid gap-5 sm:grid-cols-2">
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
@@ -148,12 +174,16 @@ const orderHref = '/store/order';
|
|||||||
{/* --- Live availability --- */}
|
{/* --- Live availability --- */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeader kicker="Availability" icon="gauge" title="Current order slots" />
|
<SectionHeader
|
||||||
|
kicker="Availability"
|
||||||
|
icon="gauge"
|
||||||
|
title="Current order slots"
|
||||||
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<p class="max-w-2xl text-base" style="color: var(--color-text-muted);">
|
<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.
|
I can only take so many requests at a time. These update as I open and
|
||||||
Last updated {av.lastUpdated}.
|
finish orders. Last updated {av.lastUpdated}.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={100}>
|
<Reveal delay={100}>
|
||||||
@@ -177,7 +207,11 @@ const orderHref = '/store/order';
|
|||||||
{/* --- Requirements / disclaimer --- */}
|
{/* --- Requirements / disclaimer --- */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeader kicker="Before you order" icon="clipboard-check" title="Requirements & policies" />
|
<SectionHeader
|
||||||
|
kicker="Before you order"
|
||||||
|
icon="clipboard-check"
|
||||||
|
title="Requirements & policies"
|
||||||
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<RequirementList items={requirements} />
|
<RequirementList items={requirements} />
|
||||||
@@ -185,14 +219,26 @@ const orderHref = '/store/order';
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- CTA --- */}
|
{/* --- 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);">
|
<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>
|
<Reveal>
|
||||||
<h3 class="font-sans text-2xl font-bold" style="color: var(--color-text);">Ready to put in a request?</h3>
|
<h3
|
||||||
|
class="font-sans text-2xl font-bold"
|
||||||
|
style="color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Ready to put in a request?
|
||||||
|
</h3>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={60}>
|
<Reveal delay={60}>
|
||||||
<p class="max-w-lg text-sm" style="color: var(--color-text-muted);">
|
<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 order form collects your request and emails it to me. No payment
|
||||||
the site. {av.ordersOpen ? '' : 'Orders are currently closed — check back soon.'}
|
is taken through the site. {
|
||||||
|
av.ordersOpen
|
||||||
|
? ""
|
||||||
|
: "Orders are currently closed — check back soon."
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={100}>
|
<Reveal delay={100}>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import ObfuscatedEmail from '@components/ObfuscatedEmail.astro';
|
|||||||
import OrderForm from '@components/store/OrderForm.tsx';
|
import OrderForm from '@components/store/OrderForm.tsx';
|
||||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
import SlotMeter from '@components/store/SlotMeter.astro';
|
||||||
import RequirementList from '@components/store/RequirementList.astro';
|
import RequirementList from '@components/store/RequirementList.astro';
|
||||||
import { requirements } from '@data/store-products';
|
import { requirements, deckBoxProduct } from '@data/store-products';
|
||||||
import { storeAvailability } from '@data/store-availability';
|
import { storeAvailability } from '@data/store-availability';
|
||||||
import { turnstileSiteKey } from '@lib/env';
|
import { turnstileSiteKey } from '@lib/env';
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ const siteKey = turnstileSiteKey();
|
|||||||
double-sided cards count as 2 slots.
|
double-sided cards count as 2 slots.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<OrderForm client:idle turnstileSiteKey={siteKey} />
|
<OrderForm client:idle turnstileSiteKey={siteKey} colors={deckBoxProduct.colors} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||