Images and content updates

This commit is contained in:
Greg Kilmer
2026-08-06 17:29:26 -04:00
parent 40b2cc8c5d
commit a2a34bfd66
30 changed files with 1287 additions and 791 deletions
+21
View File
@@ -28,6 +28,27 @@ const SITE_URL = process.env.SITE_URL ?? 'https://gregkilmer.com';
const PORT = Number(process.env.PORT ?? 4321);
const HOST = process.env.HOST ?? '0.0.0.0';
/**
* React-refresh preamble, injected into the layout <head> in dev (see
* BaseLayout.astro). Returned as a string so the layout can emit it as an
* inline module script that runs BEFORE any island module evaluates.
*
* Why this is needed: Vite 8 (Rolldown) + @vitejs/plugin-react 5.x does not
* inject this preamble through Astro's SSR dev HTML pipeline, so the compiled
* islands call $RefreshSig$() at module-eval time, throw, and never hydrate —
* surfacing as "_jsxDEV is not a function" and leaving every React island
* (carousel, mobile menu, contact/order forms, animated background) dead.
*
* Production builds are unaffected: the client bundle uses jsx/jsxs directly
* and never touches $RefreshSig$. Remove this once @astrojs/react ships
* plugin-react 6, which handles Rolldown natively.
*/
export const REACT_REFRESH_PREAMBLE =
"import { injectIntoGlobalHook } from '/@react-refresh';\n" +
'injectIntoGlobalHook(window);\n' +
'window.$RefreshReg$ = () => {};\n' +
'window.$RefreshSig$ = () => (type) => type;';
export default defineConfig({
// Used for canonical URLs, sitemaps, and absolute metadata. Keep this in sync with the
// deployed domain on your Cosmos reverse proxy.
+22
View File
@@ -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
`[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.
+862 -702
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -18,7 +18,7 @@
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^7.0.2",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@astrojs/react": "^6.0.2",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
@@ -27,7 +27,7 @@
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^7.0.6",
"astro": "^7.2.0",
"framer-motion": "^12.42.2",
"nodemailer": "^9.0.3",
"react": "^19.2.7",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+69
View File
@@ -0,0 +1,69 @@
---
/**
* ColorGrid.astro
* ---------------------------------------------------------------------------
* Renders the available print/filament colors for a product as a grid of
* swatches. Each swatch shows the color, its name, and an In-stock / Out-of-
* stock pill.
*
* The data comes from the product's `colors` array in store-products.ts. To
* manage colors, edit that array (add/remove entries, toggle `inStock`).
*
* If `colors` is omitted or empty, this component renders nothing.
*/
import type { ColorOption } from '@data/store-products';
interface Props {
colors?: ColorOption[];
}
const { colors = [] } = Astro.props;
const inStockCount = colors.filter((c) => c.inStock).length;
---
{colors.length > 0 && (
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<span class="text-sm" style="color: var(--color-text-muted);">
Choose from the available filament colors.
</span>
<span class="font-mono text-xs" style="color: var(--color-text-subtle);">
{inStockCount} of {colors.length} in stock
</span>
</div>
<ul class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{colors.map((c) => (
<li
class:list={[
'flex items-center gap-3 rounded-xl border p-3',
!c.inStock && 'opacity-60',
]}
style="border-color: var(--color-border); background: var(--color-surface-raised);"
>
{/* Swatch. A hairline ring keeps light colors (e.g. bone white)
visible against the raised surface. */}
<span
class="h-9 w-9 shrink-0 rounded-full"
style={`background: ${c.hex}; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 18%, transparent);`}
aria-hidden="true"
>
</span>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-sans text-sm font-semibold" style="color: var(--color-text);">
{c.name}
</span>
<span
class="pill text-[0.7rem]"
style={c.inStock
? undefined
: 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'}
>
{c.inStock ? 'In stock' : 'Out of stock'}
</span>
</span>
</li>
))}
</ul>
</div>
)}
+47 -2
View File
@@ -38,15 +38,34 @@ declare global {
}
}
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 }: Props) {
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);
@@ -97,6 +116,7 @@ export default function OrderForm({ turnstileSiteKey }: Props) {
name: string;
email: string;
orderType: OrderType;
color: string;
quantity: string;
details: string;
notes: string;
@@ -125,6 +145,7 @@ export default function OrderForm({ turnstileSiteKey }: Props) {
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(),
@@ -224,13 +245,37 @@ export default function OrderForm({ turnstileSiteKey }: Props) {
<div className="flex flex-col">
<label htmlFor="orderType" className={labelBase} style={fieldColor}>Order type</label>
<select id="orderType" name="orderType" defaultValue="cards" className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor }}>
<select
id="orderType"
name="orderType"
value={orderType}
onChange={(e) => setOrderType(e.target.value as OrderType)}
className={inputBase}
style={{ borderColor: 'var(--color-border)', ...fieldColor }}
>
<option value="cards">Proxy MTG cards</option>
<option value="deckBoxes">3D-printed deck box / counters</option>
<option value="both">Both</option>
</select>
</div>
{/* Color selector — only for printed items, only when colors are
configured and in stock. The list comes from store-products.ts. */}
{showColors && (
<div className="flex flex-col">
<label htmlFor="color" className={labelBase} style={fieldColor}>Print color</label>
<select id="color" name="color" defaultValue="" className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor }}>
<option value="" disabled>Select a color&hellip;</option>
{availableColors.map((c) => (
<option key={c.name} value={c.name}>{c.name}</option>
))}
</select>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
Colors shown are what&rsquo;s loaded right now. See the deck-boxes page for swatches.
</p>
</div>
)}
<div className="flex flex-col">
<label htmlFor="quantity" className={labelBase} style={fieldColor}>Approximate quantity</label>
<input id="quantity" name="quantity" type="text" placeholder="e.g. 75 cards, or 1 deck box + 4 counters" className={inputBase} style={{ borderColor: borderFor('quantity'), ...fieldColor }} aria-invalid={Boolean(errors.quantity)} aria-describedby={errors.quantity ? 'quantity-error' : undefined} />
+2 -2
View File
@@ -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.",
"slots": {
"cards": {
"open": 0,
"open": 1,
"total": 3
},
"deckBoxes": {
"open": 0,
"open": 2,
"total": 3
}
}
+82 -23
View File
@@ -100,34 +100,48 @@ export const cardProduct: Product = {
slotKey: 'cards',
images: [
{
src: '/images/proxies.jpg',
src: '/images/store/cards/proxies.jpg',
alt: 'Example printed proxy MTG cards, front faces',
label: 'Card fronts \u2014 full-color print',
},
{
src: '/images/proxies-example-1.svg',
alt: 'Placeholder: close-up of card finish and cut',
label: 'Placeholder \u2014 close-up of finish & cut',
},
{
src: '/images/proxies-example-2.svg',
alt: 'Placeholder: white card back showing the proxy marking',
label: 'Placeholder \u2014 solid white back',
},{
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.31mm laminated photo cardstock' },
{ 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',
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 see Requirements)' },
{ 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' },
@@ -151,32 +165,77 @@ export const deckBoxProduct: Product = {
images: [
{
src: '/images/deckboxes.jpg',
alt: 'Example 3D-printed deck boxes and counters',
label: 'Deck box & counters \u2014 assembled',
alt: 'Example 3D-printed deck boxes',
label: 'Deck boxes - Marble, Nebula, and Sparkle Black with Metallic Gold Accents',
},
{
src: '/images/deckboxes-example-1.svg',
alt: 'Placeholder: deck box open showing card capacity',
label: 'Placeholder \u2014 open, showing capacity',
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/deckboxes-example-2.svg',
alt: 'Placeholder: counter close-up with life totals',
label: 'Placeholder \u2014 counter detail',
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' },
{ 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. */
+15
View File
@@ -25,6 +25,14 @@ import StoreNav from "@components/store/StoreNav.astro";
import Footer from "@components/layout/Footer.astro";
import { themeInitScript } from "@/scripts/theme-init";
import { siteIdentity } from "@lib/site";
// Dev-only react-refresh preamble (see astro.config.mjs for why). Imported as a
// raw string so we can emit it as the FIRST module script in <head>, ahead of
// any React island, so $RefreshReg$/$RefreshSig$ exist before islands eval.
import { REACT_REFRESH_PREAMBLE } from "../../astro.config.mjs";
// Only emit the preamble in dev (import.meta.env.DEV). Production builds compile
// islands to jsx/jsxs and never touch the refresh runtime.
const isDev = import.meta.env.DEV;
type Surface = "neutral" | "professional" | "interest";
@@ -85,6 +93,13 @@ const ogImageUrl = ogImage
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
{/* Dev-only react-refresh preamble. MUST be the first module script so the
refresh globals exist before any React island module evaluates. Without
it, islands throw at eval time and never hydrate (see astro.config.mjs). */}
{isDev && (
<script type="module" set:html={REACT_REFRESH_PREAMBLE} />
)}
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
+7 -2
View File
@@ -36,6 +36,8 @@ 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('')),
@@ -131,7 +133,7 @@ export const POST: APIRoute = async ({ request }) => {
if (!parsed.success) {
return json({ error: 'Please complete all fields correctly, including the policy acknowledgment.', issues: parsed.error.issues }, 400);
}
const { name, email, orderType, quantity, details, notes, turnstileToken } = parsed.data;
const { name, email, orderType, color, quantity, details, notes, turnstileToken } = parsed.data;
// Rate limit.
const ip = clientIp(request);
@@ -147,7 +149,7 @@ export const POST: APIRoute = async ({ request }) => {
// Deliver.
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) {
return json({ error: 'Order delivery is not configured yet. Please email me directly.' }, 503);
}
@@ -163,6 +165,7 @@ interface OrderMessage {
name: string;
email: string;
orderType: string;
color: string;
quantity: string;
details: string;
notes: string;
@@ -180,6 +183,7 @@ async function deliver(msg: OrderMessage): Promise<boolean> {
`Name: ${msg.name}`,
`Email: ${msg.email}`,
`Order type: ${typeLabel}`,
`Color: ${msg.color || '(not specified)'}`,
`Approx quantity: ${msg.quantity}`,
``,
`Details:`,
@@ -226,6 +230,7 @@ async function deliver(msg: OrderMessage): Promise<boolean> {
name: msg.name,
email: msg.email,
orderType: typeLabel,
color: msg.color || undefined,
quantity: msg.quantity,
details: msg.details,
notes: msg.notes,
+77 -23
View File
@@ -9,24 +9,26 @@
* (deckBoxProduct.images). The specs block is image-free now; examples live in
* the carousel only.
*/
import StoreLayout from '@layouts/StoreLayout.astro';
import Reveal from '@components/motion/Reveal.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import Icon from '@components/Icon.astro';
import Carousel from '@components/store/Carousel.tsx';
import ProductCard from '@components/store/ProductCard.astro';
import PricingTable from '@components/store/PricingTable.astro';
import SlotMeter from '@components/store/SlotMeter.astro';
import RequirementList from '@components/store/RequirementList.astro';
import { deckBoxProduct, requirements } from '@data/store-products';
import { storeAvailability } from '@data/store-availability';
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'),
const keyReqs = requirements.filter(
(r) =>
r.title.toLowerCase().includes("final") ||
r.title.toLowerCase().includes("capacity") ||
r.title.toLowerCase().includes("affiliation"),
);
---
@@ -48,28 +50,54 @@ const keyReqs = requirements.filter((r) =>
</a>
</Reveal>
<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>
</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} />
<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
><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`} />
<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
><SectionHeader
kicker="Details"
icon="ruler"
title="Specifications"
/></Reveal
>
<Reveal delay={60}>
<ProductCard product={deckBoxProduct} detailed />
</Reveal>
@@ -77,7 +105,13 @@ const keyReqs = requirements.filter((r) =>
{/* Pricing */}
<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}>
<PricingTable
icon="printer-3d-nozzle"
@@ -88,9 +122,29 @@ const keyReqs = requirements.filter((r) =>
</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
><SectionHeader
kicker="Before you order"
icon="clipboard-check"
title="Key requirements"
/></Reveal
>
<Reveal delay={60}>
<RequirementList items={keyReqs} />
</Reveal>
@@ -101,7 +155,7 @@ const keyReqs = requirements.filter((r) =>
<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'}
{av.ordersOpen ? "Start an order request" : "View order page"}
</a>
</div>
</Reveal>
+79 -33
View File
@@ -10,30 +10,34 @@
* 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';
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';
const orderHref = "/store/order";
---
<StoreLayout
title="Store — Proxy MTG Cards & 3D-Printed Deck Boxes"
>
<StoreLayout title="Store — Proxy MTG Cards & 3D-Printed Deck Boxes">
{/* --- Hero --- */}
<section class="relative overflow-hidden pb-8 pt-28 sm:pt-32">
<AnimatedBackground client:idle className="vt-sky" />
<div class="container-prose relative z-10 grid items-center gap-6 py-6 sm:grid-cols-[1.4fr_1fr]">
<div
class="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
@@ -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"
style="border-color: var(--color-accent); color: var(--color-cream); opacity: 0.92;"
>
Clearly-marked proxies for casual play, and matching 3D-printed storage &amp;
counters. Hand-made in small batches.
Clearly-marked proxies for casual play, and matching 3D-printed
storage &amp; counters. Hand-made in small batches.
</p>
</Reveal>
@@ -107,10 +111,17 @@ const orderHref = '/store/order';
{/* --- Intro + limited capacity banner --- */}
<section class="flex flex-col gap-6">
<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 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}
</p>
</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);"
>
<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>
<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
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>
@@ -133,7 +155,11 @@ const orderHref = '/store/order';
{/* --- Product lines --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader kicker="Products" icon="cards-outline" title="What I make" />
<SectionHeader
kicker="Products"
icon="cards-outline"
title="What I make"
/>
</Reveal>
<div class="grid gap-5 sm:grid-cols-2">
<Reveal delay={60}>
@@ -148,12 +174,16 @@ const orderHref = '/store/order';
{/* --- Live availability --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader kicker="Availability" icon="gauge" title="Current order slots" />
<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}.
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}>
@@ -177,7 +207,11 @@ const orderHref = '/store/order';
{/* --- Requirements / disclaimer --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader kicker="Before you order" icon="clipboard-check" title="Requirements &amp; policies" />
<SectionHeader
kicker="Before you order"
icon="clipboard-check"
title="Requirements & policies"
/>
</Reveal>
<Reveal delay={60}>
<RequirementList items={requirements} />
@@ -185,14 +219,26 @@ const orderHref = '/store/order';
</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);">
<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>
<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.'}
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}>
+2 -2
View File
@@ -18,7 +18,7 @@ import ObfuscatedEmail from '@components/ObfuscatedEmail.astro';
import OrderForm from '@components/store/OrderForm.tsx';
import SlotMeter from '@components/store/SlotMeter.astro';
import RequirementList from '@components/store/RequirementList.astro';
import { requirements } from '@data/store-products';
import { requirements, deckBoxProduct } from '@data/store-products';
import { storeAvailability } from '@data/store-availability';
import { turnstileSiteKey } from '@lib/env';
@@ -97,7 +97,7 @@ const siteKey = turnstileSiteKey();
double-sided cards count as 2 slots.
</p>
<div class="mt-3">
<OrderForm client:idle turnstileSiteKey={siteKey} />
<OrderForm client:idle turnstileSiteKey={siteKey} colors={deckBoxProduct.colors} />
</div>
</div>
</Reveal>