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 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
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 {
|
||||
/** 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…</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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 &
|
||||
counters. Hand-made in small batches.
|
||||
Clearly-marked proxies for casual play, and matching 3D-printed
|
||||
storage & 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 & 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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||