mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Store base
This commit is contained in:
@@ -38,6 +38,13 @@ const ICON_PATHS: Record<string, string> = {
|
||||
'M6 3h5v5H6V3zm7 0h5v5h-5V3zM3 11h5v5H3v-5zm7 0h5v5h-5v-5zm7 0h-5v5h5v-5z',
|
||||
email:
|
||||
'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm8 7L4 6v1l8 5 8-5V6l-8 5z',
|
||||
// Store nav icons (kept here so the store mobile menu is self-contained).
|
||||
store: 'M4 7h16l-1 13H5L4 7zm5-3a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0 3 3',
|
||||
'cards-playing': 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm3 4v3m6 7v-3',
|
||||
'printer-3d-nozzle':
|
||||
'M7 2h10v4l3 3-4 2H8L4 9l3-3V2zm5 11v9',
|
||||
'clipboard-list':
|
||||
'M9 3h6v2H9V3zm-2 0v2H6a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-1V3M7 11h2m4 0h4M7 15h2m4 0h4',
|
||||
};
|
||||
|
||||
function Icon({ name, className }: { name: string; className?: string }) {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Carousel.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Server-driven image carousel for store product pages. The list of slides
|
||||
* comes from the product's `images` array (defined in store-products.ts), so
|
||||
* you manage what's shown purely from the data file.
|
||||
*
|
||||
* Features:
|
||||
* - Swipe / drag via pointer events (works for touch + mouse).
|
||||
* - A caption (label) rendered on each slide.
|
||||
* - Prev/next buttons + dot indicators.
|
||||
* - Keyboard accessible (buttons + left/right arrows when focused).
|
||||
* - Respects prefers-reduced-motion (no slide animation, instant swap).
|
||||
*
|
||||
* The carousel is presentation-only on the image; each slide's alt text
|
||||
* carries the meaning for screen readers, and the caption is rendered as
|
||||
* visible text (not aria-hidden) so it's announced.
|
||||
*/
|
||||
import { useState, useRef, useCallback, type PointerEvent } from 'react';
|
||||
|
||||
export interface CarouselSlide {
|
||||
src: string;
|
||||
alt: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
slides: CarouselSlide[];
|
||||
/** Optional aria-label for the carousel region. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function Carousel({ slides, label = 'Product examples' }: Props) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// Horizontal drag delta in px accumulated during the current gesture.
|
||||
const dragDeltaRef = useRef(0);
|
||||
const startXRef = useRef(0);
|
||||
// Live offset applied to the track during a drag (px), for visual feedback.
|
||||
const [liveOffset, setLiveOffset] = useState(0);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const count = slides.length;
|
||||
|
||||
const goTo = useCallback(
|
||||
(i: number) => {
|
||||
if (count === 0) return;
|
||||
const next = (i + count) % count;
|
||||
setIndex(next);
|
||||
},
|
||||
[count],
|
||||
);
|
||||
|
||||
const next = useCallback(() => goTo(index + 1), [goTo, index]);
|
||||
const prev = useCallback(() => goTo(index - 1), [goTo, index]);
|
||||
|
||||
// --- Pointer (touch + mouse) drag handling --------------------------------
|
||||
// Threshold in px before a drag counts as a swipe. Below it, the gesture is
|
||||
// treated as a click (ignored), so taps on the image don't advance.
|
||||
const SWIPE_THRESHOLD = 40;
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
// Only react to primary button / touch.
|
||||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
||||
setDragging(true);
|
||||
startXRef.current = e.clientX;
|
||||
dragDeltaRef.current = 0;
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
const delta = e.clientX - startXRef.current;
|
||||
dragDeltaRef.current = delta;
|
||||
setLiveOffset(delta);
|
||||
};
|
||||
|
||||
const finishDrag = () => {
|
||||
if (!dragging) return;
|
||||
const delta = dragDeltaRef.current;
|
||||
setDragging(false);
|
||||
setLiveOffset(0);
|
||||
if (delta <= -SWIPE_THRESHOLD) {
|
||||
next();
|
||||
} else if (delta >= SWIPE_THRESHOLD) {
|
||||
prev();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
prev();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
const slide = slides[index]!;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
role="group"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl border select-none"
|
||||
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface-sunken)' }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={finishDrag}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Slides track. We translate by -index*100% and apply the live drag
|
||||
offset for feedback. touch-action: pan-y lets vertical scroll pass
|
||||
through while we capture horizontal swipes. */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: `translateX(calc(${-index * 100}% + ${liveOffset}px))`,
|
||||
transitionDuration: dragging ? '0ms' : undefined,
|
||||
touchAction: 'pan-y',
|
||||
}}
|
||||
>
|
||||
{slides.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative w-full shrink-0"
|
||||
aria-hidden={i !== index}
|
||||
aria-roledescription="slide"
|
||||
aria-label={`Slide ${i + 1} of ${count}`}
|
||||
>
|
||||
<img
|
||||
src={s.src}
|
||||
alt={s.alt}
|
||||
draggable={false}
|
||||
loading={i === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
className="aspect-[16/9] w-full object-cover"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
{/* Caption gradient + label, always visible for the active slide. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end p-4"
|
||||
style={{ background: 'linear-gradient(to top, rgba(7,10,28,0.85), transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="font-sans text-sm font-semibold sm:text-base"
|
||||
style={{ color: 'var(--color-cream)' }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Prev / next controls. */}
|
||||
{count > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
aria-label="Previous slide"
|
||||
className="absolute left-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||
style={{
|
||||
color: 'var(--color-cream)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 40%, transparent)',
|
||||
background: 'rgba(7,10,28,0.5)',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
aria-label="Next slide"
|
||||
className="absolute right-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||
style={{
|
||||
color: 'var(--color-cream)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 40%, transparent)',
|
||||
background: 'rgba(7,10,28,0.5)',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slide counter (top-right). */}
|
||||
<span
|
||||
className="pointer-events-none absolute right-3 top-3 rounded-full px-2.5 py-1 font-mono text-xs"
|
||||
style={{ color: 'var(--color-cream)', background: 'rgba(7,10,28,0.6)' }}
|
||||
>
|
||||
{index + 1} / {count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dot indicators. */}
|
||||
{count > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{slides.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => goTo(i)}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
aria-current={i === index}
|
||||
className="h-2 rounded-full transition-all"
|
||||
style={{
|
||||
width: i === index ? '1.75rem' : '0.5rem',
|
||||
background:
|
||||
i === index
|
||||
? 'var(--color-accent)'
|
||||
: 'color-mix(in srgb, var(--color-text-subtle) 50%, transparent)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* sr-only live region announcing the active slide's label. */}
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{slide.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* OrderForm.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Store order-request form island. Modeled on ContactForm.tsx: same
|
||||
* idle/submitting/success/error states, honeypot, optional Turnstile, aria-live
|
||||
* status, and identical input styling. Posts to /api/order.
|
||||
*
|
||||
* This collects an ORDER REQUEST only — no payment fields, no checkout. The
|
||||
* submission is emailed to Greg. The server endpoint (/api/order) independently
|
||||
* re-checks store-availability.json and 403s if orders are closed, so the gate
|
||||
* can't be bypassed from the client.
|
||||
*
|
||||
* The form requires an explicit acknowledgment of the store policies (checkbox)
|
||||
* before it can be submitted.
|
||||
*/
|
||||
import { useState, useRef, useEffect, type FormEvent } from 'react';
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
||||
type OrderType = 'cards' | 'deckBoxes' | 'both';
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
details?: string;
|
||||
quantity?: string;
|
||||
agree?: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: string | HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
|
||||
turnstileSiteKey?: string;
|
||||
}
|
||||
|
||||
export default function OrderForm({ turnstileSiteKey }: Props) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
|
||||
const turnstileTokenRef = useRef<string>('');
|
||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Same Turnstile bootstrap as ContactForm.
|
||||
useEffect(() => {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (window.turnstile) {
|
||||
renderWidget();
|
||||
return;
|
||||
}
|
||||
let script = document.getElementById('cf-turnstile-script') as HTMLScriptElement | null;
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = 'cf-turnstile-script';
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
script.addEventListener('load', renderWidget);
|
||||
return () => {
|
||||
script?.removeEventListener('load', renderWidget);
|
||||
};
|
||||
}, [turnstileSiteKey]);
|
||||
|
||||
function renderWidget() {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (!window.turnstile || !turnstileContainerRef.current) return;
|
||||
if (turnstileWidgetIdRef.current) return;
|
||||
turnstileWidgetIdRef.current = window.turnstile.render(turnstileContainerRef.current, {
|
||||
sitekey: turnstileSiteKey,
|
||||
callback: (token: string) => {
|
||||
turnstileTokenRef.current = token;
|
||||
},
|
||||
'expired-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
'error-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
theme: 'dark',
|
||||
});
|
||||
}
|
||||
|
||||
interface OrderValues {
|
||||
name: string;
|
||||
email: string;
|
||||
orderType: OrderType;
|
||||
quantity: string;
|
||||
details: string;
|
||||
notes: string;
|
||||
agree: boolean;
|
||||
company: string;
|
||||
turnstileToken: string;
|
||||
}
|
||||
|
||||
function validate(v: OrderValues): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
if (!v.name.trim()) e.name = 'Please enter your name.';
|
||||
if (!v.email.trim()) e.email = 'Please enter your email.';
|
||||
else if (!EMAIL_RE.test(v.email)) e.email = 'That email address looks off.';
|
||||
if (!v.details.trim()) e.details = 'Please describe your order (card list or deck-box spec).';
|
||||
else if (v.details.trim().length < 10) e.details = 'A little more detail, please (10+ characters).';
|
||||
if (!v.quantity.trim()) e.quantity = 'Please enter an approximate quantity.';
|
||||
if (!v.agree) e.agree = 'You must acknowledge the policies to continue.';
|
||||
return e;
|
||||
}
|
||||
|
||||
async function onSubmit(ev: FormEvent<HTMLFormElement>) {
|
||||
ev.preventDefault();
|
||||
const form = ev.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const values: OrderValues = {
|
||||
name: String(data.get('name') ?? '').trim(),
|
||||
email: String(data.get('email') ?? '').trim(),
|
||||
orderType: (String(data.get('orderType') ?? 'cards') as OrderType),
|
||||
quantity: String(data.get('quantity') ?? '').trim(),
|
||||
details: String(data.get('details') ?? '').trim(),
|
||||
notes: String(data.get('notes') ?? '').trim(),
|
||||
agree: data.get('agree') === 'on',
|
||||
company: String(data.get('company') ?? ''),
|
||||
turnstileToken: turnstileTokenRef.current,
|
||||
};
|
||||
|
||||
const found = validate(values);
|
||||
if (turnstileSiteKey && !values.turnstileToken) {
|
||||
found.agree = found.agree ?? 'Please complete the bot check and try again.';
|
||||
}
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) return;
|
||||
|
||||
setStatus('submitting');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
throw new Error(body.error || 'Something went wrong. Please try again.');
|
||||
}
|
||||
setStatus('success');
|
||||
form.reset();
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]';
|
||||
const labelBase = 'mb-1.5 block font-sans text-sm font-medium';
|
||||
const fieldColor = { color: 'var(--color-text)' };
|
||||
const borderFor = (k: keyof FieldErrors) => (errors[k] ? 'var(--color-accent)' : 'var(--color-border)');
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
|
||||
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3 className="font-sans text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Order request sent.
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Thanks! I'll review your request and reply by email. No payment is taken through the site.
|
||||
</p>
|
||||
<button type="button" onClick={() => setStatus('idle')} className="btn btn-secondary mt-2">
|
||||
Submit another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
|
||||
{/* Honeypot */}
|
||||
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
|
||||
<label htmlFor="company">Company (leave blank)</label>
|
||||
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="name" className={labelBase} style={fieldColor}>Name</label>
|
||||
<input id="name" name="name" type="text" autoComplete="name" className={inputBase} style={{ borderColor: borderFor('name'), ...fieldColor }} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'name-error' : undefined} />
|
||||
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="email" className={labelBase} style={fieldColor}>Email</label>
|
||||
<input id="email" name="email" type="email" autoComplete="email" className={inputBase} style={{ borderColor: borderFor('email'), ...fieldColor }} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'email-error' : undefined} />
|
||||
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="orderType" className={labelBase} style={fieldColor}>Order type</label>
|
||||
<select id="orderType" name="orderType" defaultValue="cards" 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>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="quantity" className={labelBase} style={fieldColor}>Approximate quantity</label>
|
||||
<input id="quantity" name="quantity" type="text" placeholder="e.g. 75 cards, or 1 deck box + 4 counters" className={inputBase} style={{ borderColor: borderFor('quantity'), ...fieldColor }} aria-invalid={Boolean(errors.quantity)} aria-describedby={errors.quantity ? 'quantity-error' : undefined} />
|
||||
{errors.quantity && <p id="quantity-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.quantity}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="details" className={labelBase} style={fieldColor}>Card list / deck-box spec</label>
|
||||
<textarea
|
||||
id="details"
|
||||
name="details"
|
||||
rows={6}
|
||||
className={inputBase}
|
||||
style={{ borderColor: borderFor('details'), ...fieldColor, resize: 'vertical' }}
|
||||
placeholder={'For cards: paste your full card list (one per line). Remember double-sided cards count as 2 slots.\nFor deck boxes: describe capacity, color, and any counters.'}
|
||||
aria-invalid={Boolean(errors.details)}
|
||||
aria-describedby={errors.details ? 'details-error' : undefined}
|
||||
/>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
||||
The card list is final once received — cards cannot be changed for that order.
|
||||
</p>
|
||||
{errors.details && <p id="details-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.details}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="notes" className={labelBase} style={fieldColor}>Notes <span className="font-normal" style={{ color: 'var(--color-text-subtle)' }}>(optional)</span></label>
|
||||
<textarea id="notes" name="notes" rows={3} className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor, resize: 'vertical' }} />
|
||||
</div>
|
||||
|
||||
{/* Policy acknowledgment */}
|
||||
<label className="flex items-start gap-3 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<input
|
||||
id="agree"
|
||||
name="agree"
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 shrink-0"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span>
|
||||
I understand these are clearly-marked proxies (not counterfeits), the card list is final
|
||||
once received, double-sided cards count as 2 slots, and all sales are final. I am not
|
||||
requesting custom or offensive art.
|
||||
</span>
|
||||
</label>
|
||||
{errors.agree && <p className="-mt-2 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.agree}</p>}
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="rounded-lg p-3 text-sm" style={{ background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', color: 'var(--color-accent)' }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div ref={turnstileContainerRef} className="min-h-[65px]" aria-label="Bot check" />
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={status === 'submitting'} className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{status === 'submitting' ? 'Sending…' : 'Send order request'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
||||
No payment is taken through this site. Your request is emailed to Greg, who will reply to
|
||||
confirm details and availability.
|
||||
</p>
|
||||
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{status === 'submitting' ? 'Sending your order request.' : ''}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
/**
|
||||
* PricingTable.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Generic quantity/pricing table. Takes a caption + a list of tiers
|
||||
* ({ band, price, note? }) and renders a clean three-column table. Used for
|
||||
* both the card-set tiers and the deck-box print tiers.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Tier {
|
||||
band: string;
|
||||
price: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** mdi icon shown beside the caption. */
|
||||
icon?: string;
|
||||
caption: string;
|
||||
tiers: Tier[];
|
||||
/** Column header for the price column, e.g. "Price". */
|
||||
priceHeader?: string;
|
||||
/** Optional supporting line under the caption. */
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const { icon, caption, tiers, priceHeader = 'Price', subtitle } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
{icon && <Icon name={icon} class="h-5 w-5" style="color: var(--color-accent);" />}
|
||||
{caption}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span class="text-sm" style="color: var(--color-text-muted);">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border" style="border-color: var(--color-border);">
|
||||
<table class="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr style="background: var(--color-surface-sunken);">
|
||||
<th scope="col" class="px-4 py-3 font-sans text-xs font-semibold uppercase tracking-wider" style="color: var(--color-text-subtle);">
|
||||
Quantity
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-sans text-xs font-semibold uppercase tracking-wider" style="color: var(--color-text-subtle);">
|
||||
{priceHeader}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tiers.map((tier, i) => (
|
||||
<tr style={i > 0 ? 'border-top: 1px solid var(--color-border);' : undefined}>
|
||||
<td class="px-4 py-3" style="color: var(--color-text);">
|
||||
<span class="font-medium">{tier.band}</span>
|
||||
{tier.note && (
|
||||
<span class="ml-2 text-xs" style="color: var(--color-text-subtle);">({tier.note})</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-mono font-semibold" style="color: var(--color-accent);">
|
||||
{tier.price}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
/**
|
||||
* ProductCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Product specification block: a title + summary and a spec definition list
|
||||
* (size, thickness, finishes, white back, etc.). Example IMAGES live in the
|
||||
* product-page Carousel, not here, so this component is specs-only now.
|
||||
*
|
||||
* Used on the product detail pages (cards / deck-boxes). On the storefront we
|
||||
* use the compact ProductTile instead (image + title + summary).
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Product } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
/** When true, show the full spec list (detail pages). When false, just
|
||||
* title + summary. */
|
||||
detailed?: boolean;
|
||||
/** Optional link wrapped around the compact card (storefront tiles). */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const { product, detailed = false, href } = Astro.props;
|
||||
const Wrapper = href ? 'a' : 'div';
|
||||
---
|
||||
|
||||
<Wrapper
|
||||
href={href}
|
||||
class:list={[
|
||||
'group flex flex-col gap-4 rounded-2xl border p-4',
|
||||
href && 'card-interactive',
|
||||
]}
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
<Icon name={product.icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{product.title}
|
||||
</span>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{product.summary}</p>
|
||||
</div>
|
||||
|
||||
{detailed && (
|
||||
<dl class="flex flex-col gap-0 rounded-xl border text-sm" style="border-color: var(--color-border);">
|
||||
{product.specs.map((spec, i) => (
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-3 sm:flex-row sm:items-baseline sm:gap-4"
|
||||
style={i > 0 ? 'border-top: 1px solid var(--color-border);' : undefined}
|
||||
>
|
||||
<dt class="font-sans text-xs font-semibold uppercase tracking-wider sm:w-40 sm:shrink-0" style="color: var(--color-text-subtle);">
|
||||
{spec.label}
|
||||
</dt>
|
||||
<dd style="color: var(--color-text);">{spec.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</Wrapper>
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
/**
|
||||
* ProductTile.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Compact storefront tile for a product line: the first example image (from the
|
||||
* product's `images` array), the title, the summary, and a "View details" hint.
|
||||
* Wraps the whole tile in a link to the product page.
|
||||
*
|
||||
* The full image carousel lives on the product page; this is just the storefront
|
||||
* teaser. ProductCard.astro (specs-only) is used on the detail pages.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Product } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const { product, href } = Astro.props;
|
||||
const cover = product.images[0];
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class="group card-interactive flex flex-col gap-4 rounded-2xl border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
{cover && (
|
||||
<figure class="relative w-full overflow-hidden rounded-xl" style="border: 1px solid var(--color-border);">
|
||||
<img
|
||||
src={cover.src}
|
||||
alt={cover.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-[4/3] w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
{product.images.length > 1 && (
|
||||
<span
|
||||
class="pointer-events-none absolute right-2 top-2 inline-flex items-center gap-1 rounded-full px-2 py-1 font-mono text-xs"
|
||||
style="color: var(--color-cream); background: rgba(7,10,28,0.6);"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<circle cx="9" cy="12" r="1.6" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="12" r="1.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
{product.images.length}
|
||||
</span>
|
||||
)}
|
||||
</figure>
|
||||
)}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
<Icon name={product.icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{product.title}
|
||||
</span>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{product.summary}</p>
|
||||
<span class="inline-flex items-center gap-1 text-sm font-medium transition-colors" style="color: var(--color-accent);">
|
||||
View details
|
||||
<Icon name="arrow-right" class="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
/**
|
||||
* RequirementList.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders the store rules / requirements. Each item has an mdi icon that
|
||||
* signals tone: 'check-decagram' / 'information' = neutral rule,
|
||||
* 'alert' = caution, 'close-circle' = hard no / final.
|
||||
*
|
||||
* Used on the storefront and repeated on the order page; the OrderForm also
|
||||
* requires an explicit acknowledgment of these.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Requirement } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
items: Requirement[];
|
||||
/** Optional heading shown above the list. */
|
||||
heading?: string;
|
||||
}
|
||||
|
||||
const { items, heading } = Astro.props;
|
||||
|
||||
// Map the requirement icon to a tone color.
|
||||
function toneColor(icon: Requirement['icon']): string {
|
||||
switch (icon) {
|
||||
case 'alert':
|
||||
return '#e8b54a'; // amber — caution
|
||||
case 'close-circle':
|
||||
return '#e5655a'; // red — hard no
|
||||
case 'check-decagram':
|
||||
case 'information':
|
||||
default:
|
||||
return 'var(--color-accent)';
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
{heading && (
|
||||
<h3 class="font-sans text-xl font-bold" style="color: var(--color-text);">{heading}</h3>
|
||||
)}
|
||||
|
||||
<ul class="flex flex-col gap-3">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
class="flex gap-3 rounded-lg border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<span class="mt-0.5 shrink-0" style={`color: ${toneColor(item.icon)};`}>
|
||||
<Icon name={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-sans text-sm font-semibold" style="color: var(--color-text);">{item.title}</span>
|
||||
<span class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{item.body}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
/**
|
||||
* SlotMeter.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Visual "X of Y slots open" capacity meter for a store category. Reads a slot
|
||||
* status (open + total) and renders a progress-style bar plus an open/closed
|
||||
* pill. The fill width reflects open/total; when open === 0 it shows CLOSED.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
/** Category label, e.g. "Card orders" or "3D-print slots". */
|
||||
label: string;
|
||||
/** mdi icon for the row. */
|
||||
icon: string;
|
||||
/** Number of currently open slots. */
|
||||
open: number;
|
||||
/** Total slots for the category. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
const { label, icon, open, total } = Astro.props;
|
||||
const closed = open <= 0;
|
||||
const pct = total > 0 ? Math.max(0, Math.min(100, (open / total) * 100)) : 0;
|
||||
---
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-sm font-semibold" style="color: var(--color-text);">
|
||||
<Icon name={icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
class="pill"
|
||||
style={closed
|
||||
? 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'
|
||||
: undefined}
|
||||
>
|
||||
{closed ? 'Closed' : 'Open'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm" style="color: var(--color-text-muted);">
|
||||
<span>{open} of {total} slots open</span>
|
||||
<span class="font-mono text-xs" style="color: var(--color-text-subtle);">{closed ? '0%' : `${Math.round(pct)}%`}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full"
|
||||
style="background: color-mix(in srgb, var(--color-border) 60%, transparent);"
|
||||
role="meter"
|
||||
aria-valuenow={open}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={total}
|
||||
aria-label={`${label}: ${open} of ${total} slots open`}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-500"
|
||||
style={`width: ${pct}%; background: ${closed ? '#e55' : 'var(--color-accent)'};`}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
/**
|
||||
* StoreNav.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Store-only top navigation. Deliberately separate from the main NavBar: this
|
||||
* renders ONLY store links (Storefront, Cards, Deck Boxes, Order) and contains
|
||||
* NO links back to the main gregkilmer.com site, per Greg's request that the
|
||||
* two sites not be clearly linked yet.
|
||||
*
|
||||
* Visually it mirrors NavBar.astro's treatment — the dark nebula band, the
|
||||
* scroll blur, and the StarBurst mark — so it reads as the same house while
|
||||
* being unmistakably a store (a "Store" wordmark + the store.gregkilmer.com
|
||||
* domain label).
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import MobileMenu from '@components/nav/MobileMenu.tsx';
|
||||
import StarBurst from '@components/mystic/StarBurst.astro';
|
||||
|
||||
interface NavEntry {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// Single flat group (no "two wings" divider — the store is one thing).
|
||||
const storeNav: NavEntry[] = [
|
||||
{ label: 'Store', href: '/store', icon: 'store' },
|
||||
{ label: 'Cards', href: '/store/cards', icon: 'cards-playing' },
|
||||
{ label: 'Deck Boxes', href: '/store/deck-boxes', icon: 'printer-3d-nozzle' },
|
||||
{ label: 'Order', href: '/store/order', icon: 'clipboard-list' },
|
||||
];
|
||||
|
||||
const path = Astro.url.pathname;
|
||||
const isActive = (href: string) =>
|
||||
href === '/store' ? path === '/store' : path === href || path.startsWith(href + '/');
|
||||
|
||||
// MobileMenu expects the { items: [] }[] shape.
|
||||
const groups = [{ items: storeNav }];
|
||||
---
|
||||
---
|
||||
<header
|
||||
id="store-nav"
|
||||
data-nav
|
||||
class="fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300"
|
||||
>
|
||||
<div class="border-b border-transparent transition-colors duration-300" data-nav-inner>
|
||||
<nav class="container-prose flex h-16 items-center justify-between gap-4" aria-label="Store">
|
||||
<!-- Wordmark: "Greg Kilmer · Store" + the store domain label -->
|
||||
<a href="/store" class="group flex items-center gap-2 leading-tight" aria-label="Greg Kilmer Store — home">
|
||||
<StarBurst
|
||||
class="h-4 w-4 shrink-0 transition-transform duration-700 group-hover:rotate-90"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<span class="flex flex-col">
|
||||
<span class="font-sans text-base font-bold tracking-tight" style="color: var(--accent);">
|
||||
Greg Kilmer · Store
|
||||
</span>
|
||||
<span
|
||||
class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
store.gregkilmer.com
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
{storeNav.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors"
|
||||
style={active ? 'color: var(--color-accent);' : 'color: var(--color-cream); opacity: 0.85;'}
|
||||
onmouseover="this.style.opacity='1'"
|
||||
onmouseout="this.style.opacity='0.85'"
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<Icon name={item.icon} class="h-4 w-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<MobileMenu client:idle groups={groups} currentPath={path} />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* Same nebula band treatment as the main NavBar. */
|
||||
[data-nav-inner] {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(8, 10, 32, 0.92) 0%,
|
||||
rgba(12, 14, 40, 0.86) 100%
|
||||
);
|
||||
}
|
||||
[data-nav].scrolled [data-nav-inner] {
|
||||
backdrop-filter: saturate(150%) blur(12px);
|
||||
-webkit-backdrop-filter: saturate(150%) blur(12px);
|
||||
border-bottom-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 30%,
|
||||
var(--color-border)
|
||||
);
|
||||
box-shadow: 0 8px 30px -20px rgba(11, 16, 38, 0.7);
|
||||
}
|
||||
[data-nav]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(60% 120% at 85% -20%, rgba(139, 123, 216, 0.22), transparent 70%),
|
||||
radial-gradient(50% 120% at 10% -10%, rgba(232, 181, 74, 0.1), transparent 70%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Mirror NavBar's scroll handler. Re-bind on View Transitions navigations.
|
||||
function setupNavScroll() {
|
||||
const nav = document.getElementById('store-nav');
|
||||
if (!nav) return;
|
||||
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
setupNavScroll();
|
||||
document.addEventListener('astro:page-load', setupNavScroll);
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"ordersOpen": false,
|
||||
"lastUpdated": "2026-08-04",
|
||||
"turnaround": "Estimated turnaround: ~2 weeks once a list is confirmed.",
|
||||
"maxNote": "I take a limited number of orders at a time so each one gets full attention. Slots reopen as current orders finish.",
|
||||
"slots": {
|
||||
"cards": { "open": 0, "total": 5 },
|
||||
"deckBoxes": { "open": 0, "total": 4 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* store-availability.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Typed reader for store-availability.json. Both the storefront pages and the
|
||||
* /api/order endpoint read through here, so the hard order gate (ordersOpen)
|
||||
* is enforced from a single source of truth.
|
||||
*
|
||||
* To open orders: edit `./store-availability.json` and set `"ordersOpen": true`,
|
||||
* then (optionally) bump the `slots.*.open` counts and `lastUpdated`.
|
||||
*/
|
||||
import raw from './store-availability.json';
|
||||
|
||||
export interface SlotStatus {
|
||||
open: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface StoreAvailability {
|
||||
/** Master gate. false => order form hidden + /api/order returns 403. */
|
||||
ordersOpen: boolean;
|
||||
/** Free-form date string shown as "Updated <date>". */
|
||||
lastUpdated: string;
|
||||
/** Turnaround estimate shown on storefront + order page. */
|
||||
turnaround: string;
|
||||
/** Note explaining the limited-capacity model. */
|
||||
maxNote: string;
|
||||
slots: {
|
||||
cards: SlotStatus;
|
||||
deckBoxes: SlotStatus;
|
||||
};
|
||||
}
|
||||
|
||||
/** The parsed availability, asserted to the shape above. */
|
||||
export const storeAvailability = raw as StoreAvailability;
|
||||
|
||||
/** True when at least one slot is open in a category. */
|
||||
export function categoryOpen(slot: SlotStatus): boolean {
|
||||
return slot.open > 0;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* store-products.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Typed product + pricing data for the store. This is version-controlled source
|
||||
* (not the user-editable availability file) so the storefront and pricing stay
|
||||
* in sync with the components that render them.
|
||||
*
|
||||
* - Availability (orders open/closed, slot counts, turnaround) lives in
|
||||
* `./store-availability.json`, which is the simple file Greg edits.
|
||||
* - Product specs and pricing tiers live HERE.
|
||||
*
|
||||
* Prices and concrete specs are deliberately left as [Greg to fill] placeholders
|
||||
* so real numbers can be dropped in without touching component code.
|
||||
*/
|
||||
|
||||
export interface SpecRow {
|
||||
/** Label, e.g. "Size". */
|
||||
label: string;
|
||||
/** Value, e.g. "63 x 88 mm (standard MTG)". */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single example image for a product. Rendered in the product-page carousel;
|
||||
* `label` is shown as a caption on/under the image.
|
||||
*/
|
||||
export interface ProductImage {
|
||||
/** Public path, e.g. "/images/proxies.jpg". */
|
||||
src: string;
|
||||
/** Alt text for accessibility. */
|
||||
alt: string;
|
||||
/** Caption shown on the slide, e.g. "Front finish \u2014 matte". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PriceTier {
|
||||
/** Quantity band label, e.g. "1-9 cards". */
|
||||
band: string;
|
||||
/** Price for that band. Placeholder until Greg sets real prices. */
|
||||
price: string;
|
||||
/** Optional note, e.g. "per card". */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
/** mdi icon used as the decorative mark. */
|
||||
icon: string;
|
||||
/** Product name. */
|
||||
title: string;
|
||||
/** One-line summary shown on cards + the storefront. */
|
||||
summary: string;
|
||||
/**
|
||||
* Which availability slot category this product belongs to. Used so the
|
||||
* product page can show the matching SlotMeter at the top.
|
||||
*/
|
||||
slotKey: 'cards' | 'deckBoxes';
|
||||
/**
|
||||
* Example images for the product-page carousel, in display order. Each slide
|
||||
* shows the image plus its `label` caption. Add/remove entries here to
|
||||
* manage the carousel from the server side.
|
||||
*/
|
||||
images: ProductImage[];
|
||||
/** Detailed spec list rendered as a definition list. */
|
||||
specs: SpecRow[];
|
||||
/** Pricing tiers rendered as a pricing table. */
|
||||
pricing: PriceTier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy MTG card product line. Sizes/finishes are standard for the format; the
|
||||
* exact cardstock weights and finish names are Greg's to fill.
|
||||
*/
|
||||
export const cardProduct: Product = {
|
||||
icon: 'cards-playing',
|
||||
title: 'Proxy MTG Cards',
|
||||
summary:
|
||||
'High-quality printed proxy cards for casual play, sized to match real Magic cards and clearly marked as proxies.',
|
||||
slotKey: 'cards',
|
||||
images: [
|
||||
{
|
||||
src: '/images/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',
|
||||
},
|
||||
],
|
||||
specs: [
|
||||
{ label: 'Size', value: '63 x 88 mm (standard MTG card size)' },
|
||||
{ label: 'Thickness', value: '[Greg to fill] (e.g. 300 gsm / 350 gsm cardstock)' },
|
||||
{ label: 'Back', value: 'Solid white back (clearly not tournament-legal)' },
|
||||
{
|
||||
label: 'Finishes',
|
||||
value: '[Greg to fill] (e.g. matte, linen, glossy)',
|
||||
},
|
||||
{
|
||||
label: 'Double-sided cards',
|
||||
value: 'Printed as two separate cards and count as 2 card slots',
|
||||
},
|
||||
{ label: 'Art', value: 'Official card art only. No custom or offensive art (see Requirements)' },
|
||||
],
|
||||
pricing: [
|
||||
{ band: '1-9 cards', price: '[Greg to fill]', note: 'per card' },
|
||||
{ band: '10-39 cards', price: '[Greg to fill]', note: 'per card' },
|
||||
{ band: '40-99 cards', price: '[Greg to fill]', note: 'per card' },
|
||||
{ band: '100+ cards', price: '[Greg to fill]', note: 'per card' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 3D-printed deck box + counters product line.
|
||||
*/
|
||||
export const deckBoxProduct: Product = {
|
||||
icon: 'printer-3d-nozzle',
|
||||
title: '3D-Printed Deck Boxes & Counters',
|
||||
summary:
|
||||
'Custom 3D-printed deck boxes, life counters, and tokens to store and track your games.',
|
||||
slotKey: 'deckBoxes',
|
||||
images: [
|
||||
{
|
||||
src: '/images/deckboxes.jpg',
|
||||
alt: 'Example 3D-printed deck boxes and counters',
|
||||
label: 'Deck box & counters \u2014 assembled',
|
||||
},
|
||||
{
|
||||
src: '/images/deckboxes-example-1.svg',
|
||||
alt: 'Placeholder: deck box open showing card capacity',
|
||||
label: 'Placeholder \u2014 open, showing capacity',
|
||||
},
|
||||
{
|
||||
src: '/images/deckboxes-example-2.svg',
|
||||
alt: 'Placeholder: counter close-up with life totals',
|
||||
label: 'Placeholder \u2014 counter detail',
|
||||
},
|
||||
],
|
||||
specs: [
|
||||
{ label: 'Capacity', value: '[Greg to fill] (e.g. 100 double-sleeved cards)' },
|
||||
{ label: 'Material', value: '[Greg to fill] (e.g. PLA / PETG)' },
|
||||
{ label: 'Finish', value: '[Greg to fill] (e.g. as-printed, sanded, painted)' },
|
||||
{ label: 'Sizing', value: '[Greg to fill] (deck box + matching counters)' },
|
||||
{ label: 'Customization', value: 'Color selection from available filament' },
|
||||
],
|
||||
pricing: [
|
||||
{ band: 'Single deck box', price: '[Greg to fill]', note: 'per print' },
|
||||
{ band: 'Deck box + counter set', price: '[Greg to fill]', note: 'per set' },
|
||||
{ band: 'Counter / token only', price: '[Greg to fill]', note: 'per print' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Both product lines, for storefront iteration. */
|
||||
export const products = [cardProduct, deckBoxProduct];
|
||||
|
||||
/**
|
||||
* The store requirements / rules. Rendered on the storefront and repeated on
|
||||
* the order page; the order form requires an explicit acknowledgment of these.
|
||||
* Wording mirrors what Greg asked to state clearly.
|
||||
*/
|
||||
export interface Requirement {
|
||||
/** mdi icon: 'check' for a rule, 'alert' for a hard limit/warning. */
|
||||
icon: 'check-decagram' | 'alert' | 'close-circle' | 'information';
|
||||
/** Short title. */
|
||||
title: string;
|
||||
/** One-to-two sentence explanation. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const requirements: Requirement[] = [
|
||||
{
|
||||
icon: 'information',
|
||||
title: 'Card list is final',
|
||||
body: 'You must provide a complete card list up front. Once the list is received, the cards in that order cannot be changed.',
|
||||
},
|
||||
{
|
||||
icon: 'alert',
|
||||
title: 'Double-sided cards = 2 slots',
|
||||
body: 'Double-sided cards are printed as two separate cards (front and back) and count as 2 card slots in your order.',
|
||||
},
|
||||
{
|
||||
icon: 'close-circle',
|
||||
title: 'All sales are final',
|
||||
body: 'Payment is final. Because each order is printed to spec, refunds and exchanges are not available.',
|
||||
},
|
||||
{
|
||||
icon: 'alert',
|
||||
title: 'These are proxies, not counterfeits',
|
||||
body: 'All cards are clearly marked as proxies with a solid white back. They are not tournament-legal and are not made to pass as genuine.',
|
||||
},
|
||||
{
|
||||
icon: 'close-circle',
|
||||
title: 'No affiliation with Wizards of the Coast',
|
||||
body: 'I am not associated with Wizards of the Coast or any game company. Magic: The Gathering and all related properties belong to their respective owners.',
|
||||
},
|
||||
{
|
||||
icon: 'close-circle',
|
||||
title: 'No counterfeiting',
|
||||
body: 'I will not counterfeit cards. Every card is produced as a clearly identifiable proxy for casual play.',
|
||||
},
|
||||
{
|
||||
icon: 'alert',
|
||||
title: 'No custom or offensive art',
|
||||
body: 'I will not print custom art or any cards I find to be offensive. Only official card art is used.',
|
||||
},
|
||||
{
|
||||
icon: 'information',
|
||||
title: 'Limited capacity',
|
||||
body: 'I can only take a limited number of orders at a time. Slots are shown live on the order page and reopen as orders finish.',
|
||||
},
|
||||
];
|
||||
@@ -21,6 +21,7 @@ import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
import NavBar from "@components/nav/NavBar.astro";
|
||||
import StoreNav from "@components/store/StoreNav.astro";
|
||||
import Footer from "@components/layout/Footer.astro";
|
||||
import { themeInitScript } from "@/scripts/theme-init";
|
||||
import { siteIdentity } from "@lib/site";
|
||||
@@ -37,6 +38,9 @@ interface Props {
|
||||
/** When true, emit <meta name="robots" content="noindex, nofollow">. Use on
|
||||
pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */
|
||||
noindex?: boolean;
|
||||
/** When true, render the store-only nav (StoreNav) instead of the main NavBar.
|
||||
Used by store pages, which intentionally share no links with the main site. */
|
||||
storeNav?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -45,6 +49,7 @@ const {
|
||||
surface = "neutral",
|
||||
ogImage,
|
||||
noindex = false,
|
||||
storeNav = false,
|
||||
} = Astro.props;
|
||||
|
||||
// True on the Home page. Used to switch the NavBar's view-transition-name so
|
||||
@@ -134,7 +139,7 @@ const ogImageUrl = ogImage
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
<NavBar isHome={isHome} />
|
||||
{storeNav ? <StoreNav /> : <NavBar isHome={isHome} />}
|
||||
|
||||
<main id="main">
|
||||
<slot />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
/**
|
||||
* StoreLayout.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Thin wrapper around BaseLayout for all store pages. Pins:
|
||||
* - surface="interest" (warm candlelit tone — clearly a store, still on-brand)
|
||||
* - storeNav (renders StoreNav, NOT the main NavBar — no cross-links)
|
||||
* - noindex=true by default (store stays out of search until Greg is ready)
|
||||
*
|
||||
* Store pages pass their own title/description; everything else is defaulted
|
||||
* here so each page stays short.
|
||||
*/
|
||||
import BaseLayout from '@layouts/BaseLayout.astro';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
/** Override the default noindex=true if you want the store indexed. */
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Greg Kilmer\u2019s store: clearly-marked proxy MTG cards and 3D-printed deck boxes & counters. Order requests by email.',
|
||||
ogImage,
|
||||
noindex = true,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
surface="interest"
|
||||
storeNav
|
||||
noindex={noindex}
|
||||
ogImage={ogImage}
|
||||
>
|
||||
<slot />
|
||||
</BaseLayout>
|
||||
@@ -79,6 +79,12 @@ export const smtpPass = (): string | undefined => env('SMTP_PASS');
|
||||
/** Inbox that receives contact form submissions. */
|
||||
export const contactTo = (): string | undefined => env('CONTACT_TO');
|
||||
|
||||
/**
|
||||
* Inbox that receives store order submissions. Falls back to CONTACT_TO so the
|
||||
* store works without a separate inbox configured. Used by /api/order.
|
||||
*/
|
||||
export const orderTo = (): string | undefined => env('ORDER_TO') ?? contactTo();
|
||||
|
||||
/** From address for outgoing mail. */
|
||||
export const smtpFrom = (): string | undefined => env('SMTP_FROM');
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* /api/order
|
||||
* ---------------------------------------------------------------------------
|
||||
* POST endpoint for store order requests. Pipeline mirrors /api/contact:
|
||||
* 1. Reject non-POST.
|
||||
* 2. HARD GATE: read store-availability.json; if ordersOpen is false, return
|
||||
* 403 immediately. This enforces the lock server-side so it can't be
|
||||
* bypassed by POSTing directly while the client form is hidden.
|
||||
* 3. Validate JSON body with Zod.
|
||||
* 4. Rate-limit per IP (in-memory windowed counter).
|
||||
* 5. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set).
|
||||
* 6. Deliver via SMTP (nodemailer) if configured, else webhook, else 503.
|
||||
*
|
||||
* This collects an ORDER REQUEST only — no payment is processed. The payload is
|
||||
* emailed to the configured inbox (ORDER_TO, falling back to CONTACT_TO).
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
contactWebhookUrl,
|
||||
env,
|
||||
orderTo,
|
||||
smtpFrom,
|
||||
smtpHost,
|
||||
smtpPass,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
turnstileSecretKey,
|
||||
} from '@lib/env';
|
||||
import { storeAvailability } from '@data/store-availability';
|
||||
|
||||
// --- Validation schema ------------------------------------------------------
|
||||
const orderTypeEnum = z.enum(['cards', 'deckBoxes', 'both']);
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
email: z.string().email().max(200),
|
||||
orderType: orderTypeEnum.default('cards'),
|
||||
quantity: z.string().min(1).max(200),
|
||||
details: z.string().min(10).max(20000),
|
||||
notes: z.string().max(5000).optional().or(z.literal('')),
|
||||
// Policy acknowledgment checkbox must be ticked.
|
||||
agree: z.literal(true),
|
||||
// Honeypot: must be empty.
|
||||
company: z.string().max(0).optional().or(z.literal('')),
|
||||
// Turnstile token (required when TURNSTILE_SECRET_KEY is set).
|
||||
turnstileToken: z.string().optional(),
|
||||
});
|
||||
|
||||
// Human-readable label for the order type, used in the email subject/body.
|
||||
const orderTypeLabel: Record<string, string> = {
|
||||
cards: 'Proxy MTG cards',
|
||||
deckBoxes: '3D-printed deck box / counters',
|
||||
both: 'Cards + 3D prints',
|
||||
};
|
||||
|
||||
// --- In-memory rate limiter (per worker) ------------------------------------
|
||||
// Separate counter namespace from /api/contact so order submits don't share
|
||||
// the contact budget. Tunables read via env helper for dev + Docker parity.
|
||||
const hits = new Map<string, { count: number; reset: number }>();
|
||||
|
||||
function rateLimited(ip: string): boolean {
|
||||
const rateWindowMin = Number(env('ORDER_RATE_WINDOW_MIN') ?? env('CONTACT_RATE_WINDOW_MIN') ?? 15);
|
||||
const rateLimit = Number(env('ORDER_RATE_LIMIT') ?? env('CONTACT_RATE_LIMIT') ?? 5);
|
||||
const now = Date.now();
|
||||
const entry = hits.get(ip);
|
||||
if (!entry || now > entry.reset) {
|
||||
hits.set(ip, { count: 1, reset: now + rateWindowMin * 60_000 });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > rateLimit;
|
||||
}
|
||||
|
||||
// --- Helpers ----------------------------------------------------------------
|
||||
function json(body: unknown, status: number) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function clientIp(request: Request): string {
|
||||
const fwd = request.headers.get('x-forwarded-for');
|
||||
if (fwd) return fwd.split(',')[0]!.trim();
|
||||
return request.headers.get('x-real-ip') ?? 'unknown';
|
||||
}
|
||||
|
||||
// --- Turnstile verification (same logic as /api/contact) --------------------
|
||||
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
|
||||
const secret = turnstileSecretKey();
|
||||
if (!secret) return true; // Turnstile not configured -> skip.
|
||||
if (!token) return false;
|
||||
try {
|
||||
const form = new URLSearchParams();
|
||||
form.set('secret', secret);
|
||||
form.set('response', token);
|
||||
if (ip && ip !== 'unknown') form.set('remoteip', ip);
|
||||
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
return Boolean(data.success);
|
||||
} catch (err) {
|
||||
console.error('[order] turnstile verification failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Route handler ----------------------------------------------------------
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed.' }, 405);
|
||||
}
|
||||
|
||||
// HARD GATE: orders closed -> reject before any other work. Re-reading the
|
||||
// JSON module here reflects whatever Greg committed to the file at deploy.
|
||||
if (!storeAvailability.ordersOpen) {
|
||||
return json({ error: 'Orders are currently closed. Please check back soon.' }, 403);
|
||||
}
|
||||
|
||||
// Parse + validate.
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON body.' }, 400);
|
||||
}
|
||||
const parsed = schema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return json({ error: 'Please complete all fields correctly, including the policy acknowledgment.', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
const { name, email, orderType, quantity, details, notes, turnstileToken } = parsed.data;
|
||||
|
||||
// Rate limit.
|
||||
const ip = clientIp(request);
|
||||
if (rateLimited(ip)) {
|
||||
return json({ error: 'Too many submissions. Please try again later.' }, 429);
|
||||
}
|
||||
|
||||
// Turnstile.
|
||||
const turnstileOk = await verifyTurnstile(turnstileToken, ip);
|
||||
if (!turnstileOk) {
|
||||
return json({ error: 'Bot check failed. Please refresh and try again.' }, 400);
|
||||
}
|
||||
|
||||
// Deliver.
|
||||
try {
|
||||
const delivered = await deliver({ name, email, orderType, quantity, details, notes: notes ?? '' });
|
||||
if (!delivered) {
|
||||
return json({ error: 'Order delivery is not configured yet. Please email me directly.' }, 503);
|
||||
}
|
||||
return json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error('[order] delivery failed:', err);
|
||||
return json({ error: 'Could not send your order right now. Please try again later.' }, 502);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Delivery: SMTP first, webhook fallback ---------------------------------
|
||||
interface OrderMessage {
|
||||
name: string;
|
||||
email: string;
|
||||
orderType: string;
|
||||
quantity: string;
|
||||
details: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
async function deliver(msg: OrderMessage): Promise<boolean> {
|
||||
const host = smtpHost();
|
||||
const webhook = contactWebhookUrl();
|
||||
const typeLabel = orderTypeLabel[msg.orderType] ?? msg.orderType;
|
||||
|
||||
// Plain-text body for the email.
|
||||
const text = [
|
||||
`New store order request`,
|
||||
``,
|
||||
`Name: ${msg.name}`,
|
||||
`Email: ${msg.email}`,
|
||||
`Order type: ${typeLabel}`,
|
||||
`Approx quantity: ${msg.quantity}`,
|
||||
``,
|
||||
`Details:`,
|
||||
msg.details,
|
||||
``,
|
||||
msg.notes ? `Notes:\n${msg.notes}` : '',
|
||||
].join('\n');
|
||||
|
||||
if (host) {
|
||||
const nodemailer = (await import('nodemailer')).default;
|
||||
const port = Number(smtpPort() ?? 587);
|
||||
const user = smtpUser();
|
||||
const allowSelfSigned = env('SMTP_ALLOW_SELFSIGNED') === 'true';
|
||||
const transport = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: user ? { user, pass: smtpPass() } : undefined,
|
||||
...(allowSelfSigned ? { tls: { rejectUnauthorized: false } } : {}),
|
||||
});
|
||||
const hostname = (() => {
|
||||
try {
|
||||
return new URL(process.env.SITE_URL ?? 'https://gregkilmer.com').hostname;
|
||||
} catch {
|
||||
return 'gregkilmer.com';
|
||||
}
|
||||
})();
|
||||
await transport.sendMail({
|
||||
from: smtpFrom() ?? `Greg's Store <no-reply@${hostname}>`,
|
||||
to: orderTo() ?? 'greg@gregkilmer.com',
|
||||
replyTo: `${msg.name} <${msg.email}>`,
|
||||
subject: `New store order — ${typeLabel} (${msg.name})`,
|
||||
text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (webhook) {
|
||||
const res = await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `New store order from ${msg.name} (${msg.email}) — ${typeLabel}`,
|
||||
name: msg.name,
|
||||
email: msg.email,
|
||||
orderType: typeLabel,
|
||||
quantity: msg.quantity,
|
||||
details: msg.details,
|
||||
notes: msg.notes,
|
||||
}),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
/**
|
||||
* /store/cards — Proxy MTG cards product line
|
||||
* ---------------------------------------------------------------------------
|
||||
* Order: back-link + title → live slot tracker → image carousel → specs →
|
||||
* pricing → double-sided callout → key requirements → CTA.
|
||||
*
|
||||
* The carousel images + labels are managed server-side in store-products.ts
|
||||
* (cardProduct.images). The specs block is image-free now; examples live in
|
||||
* the carousel only.
|
||||
*/
|
||||
import StoreLayout from '@layouts/StoreLayout.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Carousel from '@components/store/Carousel.tsx';
|
||||
import ProductCard from '@components/store/ProductCard.astro';
|
||||
import PricingTable from '@components/store/PricingTable.astro';
|
||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
||||
import RequirementList from '@components/store/RequirementList.astro';
|
||||
import { cardProduct, requirements } from '@data/store-products';
|
||||
import { storeAvailability } from '@data/store-availability';
|
||||
|
||||
const av = storeAvailability;
|
||||
const slot = av.slots[cardProduct.slotKey];
|
||||
// The double-sided rule is important enough to surface as its own callout.
|
||||
const doubleSided = requirements.find((r) => r.title.toLowerCase().includes('double-sided'))!;
|
||||
const keyReqs = requirements.filter((r) =>
|
||||
r.title.toLowerCase().includes('card list')
|
||||
|| r.title.toLowerCase().includes('double-sided')
|
||||
|| r.title.toLowerCase().includes('proxies')
|
||||
|| r.title.toLowerCase().includes('custom'),
|
||||
);
|
||||
---
|
||||
|
||||
<StoreLayout
|
||||
title="Proxy MTG Cards"
|
||||
description="Proxy MTG cards printed to standard size with a solid white back — clearly marked proxies for casual play. Specs and pricing."
|
||||
>
|
||||
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
|
||||
{/* Header */}
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/store"
|
||||
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
Back to store
|
||||
</a>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<SectionHeader kicker="Product" icon={cardProduct.icon} title={cardProduct.title} subtitle={cardProduct.summary} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Live slot tracker for this product line (top of content). */}
|
||||
<section class="flex flex-col gap-3">
|
||||
<Reveal>
|
||||
<SlotMeter label="Card order slots" icon="cards-playing" open={slot.open} total={slot.total} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Image carousel (server-driven, swipeable). */}
|
||||
<section class="flex flex-col gap-4">
|
||||
<Reveal><SectionHeader kicker="Examples" icon="image-multiple" title={`See the ${cardProduct.title.toLowerCase()}`} /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<Carousel client:visible slides={cardProduct.images} label={`${cardProduct.title} examples`} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Specifications (no image — examples live in the carousel). */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Details" icon="ruler" title="Specifications" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<ProductCard product={cardProduct} detailed />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Pricing" icon="currency-usd" title="Card sets" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<PricingTable
|
||||
icon="cards-playing"
|
||||
caption="Per-card pricing by quantity"
|
||||
subtitle="Final pricing confirmed when your list is received. Prices are placeholders pending update."
|
||||
tiers={cardProduct.pricing}
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Double-sided callout */}
|
||||
<Reveal>
|
||||
<div
|
||||
class="flex items-start gap-4 rounded-xl border p-5"
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
|
||||
>
|
||||
<Icon name="alert" class="mt-0.5 h-6 w-6 shrink-0" style="color: var(--color-accent);" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-sans text-base font-bold" style="color: var(--color-text);">{doubleSided.title}</span>
|
||||
<span class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{doubleSided.body}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Key requirements */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Before you order" icon="clipboard-check" title="Key requirements" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<RequirementList items={keyReqs} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<Reveal>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<a href="/store/order" class="btn btn-primary">
|
||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||
{av.ordersOpen ? 'Start an order request' : 'View order page'}
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</StoreLayout>
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
/**
|
||||
* /store/deck-boxes — 3D-printed deck boxes & counters product line
|
||||
* ---------------------------------------------------------------------------
|
||||
* Order: back-link + title → live slot tracker → image carousel → specs →
|
||||
* pricing → key requirements → CTA.
|
||||
*
|
||||
* The carousel images + labels are managed server-side in store-products.ts
|
||||
* (deckBoxProduct.images). The specs block is image-free now; examples live in
|
||||
* the carousel only.
|
||||
*/
|
||||
import StoreLayout from '@layouts/StoreLayout.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Carousel from '@components/store/Carousel.tsx';
|
||||
import ProductCard from '@components/store/ProductCard.astro';
|
||||
import PricingTable from '@components/store/PricingTable.astro';
|
||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
||||
import RequirementList from '@components/store/RequirementList.astro';
|
||||
import { deckBoxProduct, requirements } from '@data/store-products';
|
||||
import { storeAvailability } from '@data/store-availability';
|
||||
|
||||
const av = storeAvailability;
|
||||
const slot = av.slots[deckBoxProduct.slotKey];
|
||||
const keyReqs = requirements.filter((r) =>
|
||||
r.title.toLowerCase().includes('final')
|
||||
|| r.title.toLowerCase().includes('capacity')
|
||||
|| r.title.toLowerCase().includes('affiliation'),
|
||||
);
|
||||
---
|
||||
|
||||
<StoreLayout
|
||||
title="3D-Printed Deck Boxes & Counters"
|
||||
description="3D-printed deck boxes, life counters, and tokens. Specs and pricing for printed storage and trackers."
|
||||
>
|
||||
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
|
||||
{/* Header */}
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/store"
|
||||
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
Back to store
|
||||
</a>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<SectionHeader kicker="Product" icon={deckBoxProduct.icon} title={deckBoxProduct.title} subtitle={deckBoxProduct.summary} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Live slot tracker for this product line (top of content). */}
|
||||
<section class="flex flex-col gap-3">
|
||||
<Reveal>
|
||||
<SlotMeter label="3D-print slots" icon="printer-3d-nozzle" open={slot.open} total={slot.total} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Image carousel (server-driven, swipeable). */}
|
||||
<section class="flex flex-col gap-4">
|
||||
<Reveal><SectionHeader kicker="Examples" icon="image-multiple" title={`See the ${deckBoxProduct.title.toLowerCase()}`} /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<Carousel client:visible slides={deckBoxProduct.images} label={`${deckBoxProduct.title} examples`} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Specifications (no image — examples live in the carousel). */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Details" icon="ruler" title="Specifications" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<ProductCard product={deckBoxProduct} detailed />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Pricing" icon="currency-usd" title="Prints" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<PricingTable
|
||||
icon="printer-3d-nozzle"
|
||||
caption="Per-print pricing"
|
||||
subtitle="Final pricing confirmed when your request is received. Prices are placeholders pending update."
|
||||
tiers={deckBoxProduct.pricing}
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Key requirements */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Before you order" icon="clipboard-check" title="Key requirements" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<RequirementList items={keyReqs} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<Reveal>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<a href="/store/order" class="btn btn-primary">
|
||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||
{av.ordersOpen ? 'Start an order request' : 'View order page'}
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</StoreLayout>
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
/**
|
||||
* /store — Storefront
|
||||
* ---------------------------------------------------------------------------
|
||||
* The store home: hero on the animated field, intro, the two product lines,
|
||||
* live availability (slots) from store-availability.json, the disclaimer /
|
||||
* requirements block, and a CTA to the order page.
|
||||
*
|
||||
* Reuses the main site's aesthetic (InterestHero-style hero, Reveal, cards)
|
||||
* but is rendered through StoreLayout so it gets the store-only nav and
|
||||
* noindex by default. No links back to the main site.
|
||||
*/
|
||||
import StoreLayout from '@layouts/StoreLayout.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
||||
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
|
||||
import StarBurst from '@components/mystic/StarBurst.astro';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import ProductTile from '@components/store/ProductTile.astro';
|
||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
||||
import RequirementList from '@components/store/RequirementList.astro';
|
||||
import { cardProduct, deckBoxProduct, requirements } from '@data/store-products';
|
||||
import { storeAvailability } from '@data/store-availability';
|
||||
|
||||
const av = storeAvailability;
|
||||
const orderHref = '/store/order';
|
||||
---
|
||||
|
||||
<StoreLayout
|
||||
title="Store — Proxy MTG Cards & 3D-Printed Deck Boxes"
|
||||
>
|
||||
{/* --- Hero --- */}
|
||||
<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="flex flex-col items-start gap-4">
|
||||
<Reveal>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="store" class="h-4 w-4" />
|
||||
store.gregkilmer.com
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<span
|
||||
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="cards" class="h-6 w-6" />
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={100}>
|
||||
<h2
|
||||
class="font-sans font-bold tracking-tight"
|
||||
style="color: var(--color-cream); font-size: clamp(1.75rem, 2vw + 1rem, 2.75rem); line-height: 1.15;"
|
||||
>
|
||||
Proxy MTG cards & 3D-printed deck boxes
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={140}>
|
||||
<p
|
||||
class="max-w-xl border-l-2 pl-4 font-sans text-base italic sm:text-lg"
|
||||
style="border-color: var(--color-accent); color: var(--color-cream); opacity: 0.92;"
|
||||
>
|
||||
Clearly-marked proxies for casual play, and matching 3D-printed storage &
|
||||
counters. Hand-made in small batches.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={180}>
|
||||
<a href={orderHref} class="btn btn-primary mt-2">
|
||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||
Start an order request
|
||||
</a>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={80} class="w-full">
|
||||
<figure
|
||||
class="relative w-full overflow-hidden rounded-2xl border"
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);"
|
||||
>
|
||||
<img
|
||||
src="/images/proxies.jpg"
|
||||
alt="Example printed proxy MTG cards"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute left-3 top-3 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-accent)_60%,transparent)]"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
</figure>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
||||
{/* --- Intro + limited capacity banner --- */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader kicker="Welcome" icon="store" title="A small workshop, not a factory" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<p class="max-w-2xl text-lg leading-relaxed" style="color: var(--color-text-muted);">
|
||||
{av.maxNote}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon name="clock-outline" class="h-6 w-6 shrink-0" style="color: var(--color-accent);" />
|
||||
<span class="text-sm sm:text-base" style="color: var(--color-text);">{av.turnaround}</span>
|
||||
</div>
|
||||
<span class="pill" style={av.ordersOpen ? undefined : 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'}>
|
||||
{av.ordersOpen ? 'Orders open' : 'Orders currently closed'}
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* --- Product lines --- */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader kicker="Products" icon="cards-outline" title="What I make" />
|
||||
</Reveal>
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Reveal delay={60}>
|
||||
<ProductTile product={cardProduct} href="/store/cards" />
|
||||
</Reveal>
|
||||
<Reveal delay={120}>
|
||||
<ProductTile product={deckBoxProduct} href="/store/deck-boxes" />
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Live availability --- */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader kicker="Availability" icon="gauge" title="Current order slots" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<p class="max-w-2xl text-base" style="color: var(--color-text-muted);">
|
||||
I can only take so many requests at a time. These update as I open and finish orders.
|
||||
Last updated {av.lastUpdated}.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<SlotMeter
|
||||
label="Card orders"
|
||||
icon="cards-playing"
|
||||
open={av.slots.cards.open}
|
||||
total={av.slots.cards.total}
|
||||
/>
|
||||
<SlotMeter
|
||||
label="3D-print slots"
|
||||
icon="printer-3d-nozzle"
|
||||
open={av.slots.deckBoxes.open}
|
||||
total={av.slots.deckBoxes.total}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* --- Requirements / disclaimer --- */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader kicker="Before you order" icon="clipboard-check" title="Requirements & policies" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<RequirementList items={requirements} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* --- CTA --- */}
|
||||
<section class="flex flex-col items-center gap-4 rounded-2xl border p-8 text-center" style="border-color: var(--color-border); background: var(--color-surface-raised);">
|
||||
<Reveal>
|
||||
<h3 class="font-sans text-2xl font-bold" style="color: var(--color-text);">Ready to put in a request?</h3>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<p class="max-w-lg text-sm" style="color: var(--color-text-muted);">
|
||||
The order form collects your request and emails it to me. No payment is taken through
|
||||
the site. {av.ordersOpen ? '' : 'Orders are currently closed — check back soon.'}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<a href={orderHref} class="btn btn-primary">
|
||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||
Go to the order page
|
||||
</a>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
</StoreLayout>
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
/**
|
||||
* /store/order — Order request page
|
||||
* ---------------------------------------------------------------------------
|
||||
* Hard-gated by store-availability.json:
|
||||
* - ordersOpen === false => renders a "closed" notice + email link, NO form.
|
||||
* - ordersOpen === true => renders the OrderForm island + slot meters +
|
||||
* the requirements list.
|
||||
*
|
||||
* The server endpoint /api/order re-checks the same file and returns 403 when
|
||||
* closed, so the gate can't be bypassed by POSTing directly.
|
||||
*/
|
||||
import StoreLayout from '@layouts/StoreLayout.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import ObfuscatedEmail from '@components/ObfuscatedEmail.astro';
|
||||
import OrderForm from '@components/store/OrderForm.tsx';
|
||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
||||
import RequirementList from '@components/store/RequirementList.astro';
|
||||
import { requirements } from '@data/store-products';
|
||||
import { storeAvailability } from '@data/store-availability';
|
||||
import { turnstileSiteKey } from '@lib/env';
|
||||
|
||||
const av = storeAvailability;
|
||||
const open = av.ordersOpen;
|
||||
const siteKey = turnstileSiteKey();
|
||||
---
|
||||
|
||||
<StoreLayout
|
||||
title="Order Request"
|
||||
description="Submit a proxy-card or 3D-print order request. No payment is taken through the site."
|
||||
>
|
||||
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/store"
|
||||
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
Back to store
|
||||
</a>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<SectionHeader kicker="Order" icon="clipboard-list" title="Order request" />
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<p class="max-w-2xl text-lg leading-relaxed" style="color: var(--color-text-muted);">
|
||||
This form sends me an order request by email. No payment is taken through the site.
|
||||
{av.turnaround}
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Availability meters — shown first so visitors immediately see whether
|
||||
there's capacity before reading the rest of the page. */}
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal><SectionHeader kicker="Availability" icon="gauge" title="Current order slots" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<SlotMeter label="Card orders" icon="cards-playing" open={av.slots.cards.open} total={av.slots.cards.total} />
|
||||
<SlotMeter label="3D-print slots" icon="printer-3d-nozzle" open={av.slots.deckBoxes.open} total={av.slots.deckBoxes.total} />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Open/closed status banner (under the meters). */}
|
||||
<Reveal delay={100}>
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
|
||||
style={open
|
||||
? 'border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);'
|
||||
: 'border-color: color-mix(in srgb, #e55 40%, transparent); background: color-mix(in srgb, #e55 10%, transparent);'}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon name={open ? 'check-circle' : 'close-circle'} class="h-6 w-6 shrink-0" style={open ? 'color: var(--color-accent);' : 'color: #e55;'} />
|
||||
<span class="text-sm sm:text-base" style="color: var(--color-text);">
|
||||
{open ? 'Orders are open. Slots are limited.' : 'Orders are currently closed.'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs" style="color: var(--color-text-subtle);">
|
||||
Updated {av.lastUpdated}
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Either the form (open) or the closed notice (no form). */}
|
||||
{open ? (
|
||||
<section class="flex flex-col gap-8 lg:grid lg:grid-cols-[1.6fr_1fr] lg:items-start">
|
||||
<Reveal>
|
||||
<div class="card flex flex-col gap-2">
|
||||
<h3 class="font-sans text-lg font-bold" style="color: var(--color-text);">Your request</h3>
|
||||
<p class="text-sm" style="color: var(--color-text-muted);">
|
||||
Fill in the details below. Remember: the card list is final once received, and
|
||||
double-sided cards count as 2 slots.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<OrderForm client:idle turnstileSiteKey={siteKey} />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={80}>
|
||||
<div class="flex flex-col gap-4 lg:sticky lg:top-24">
|
||||
<RequirementList items={requirements} heading="Policies & requirements" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
) : (
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 rounded-2xl border p-8 text-center"
|
||||
style="border-color: color-mix(in srgb, #e55 40%, transparent); background: color-mix(in srgb, #e55 8%, transparent);"
|
||||
>
|
||||
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full" style="background: color-mix(in srgb, #e55 20%, transparent); color: #e55;" aria-hidden="true">
|
||||
<Icon name="lock" class="h-6 w-6" />
|
||||
</span>
|
||||
<h3 class="font-sans text-xl font-bold" style="color: var(--color-text);">Orders are not being taken right now</h3>
|
||||
<p class="max-w-md text-sm" style="color: var(--color-text-muted);">
|
||||
I open a limited number of slots at a time. Check back here — the meters above
|
||||
show live availability, and the order form appears as soon as orders reopen. You can
|
||||
also email me directly with questions.
|
||||
</p>
|
||||
<div class="mt-1 flex flex-wrap items-center justify-center gap-2">
|
||||
<Icon name="email" class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
<ObfuscatedEmail address="orders@gregkilmer.com" class="font-medium" style="color: var(--color-accent);" />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<RequirementList items={requirements} heading="Policies & requirements (for when orders open)" />
|
||||
</Reveal>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</StoreLayout>
|
||||
Reference in New Issue
Block a user