Files
com.gregkilmer.www/legacy/src/components/store/OrderForm.tsx
T
Claude a2f723ca2d Start the rebuild: note schema, vault kit and data migration
Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 18:40:55 +00:00

349 lines
15 KiB
TypeScript

/**
* 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';
import BrandIcon from '@components/brand/BrandIcon';
type Status = 'idle' | 'submitting' | 'success' | 'error';
type OrderType = 'cards' | 'deckBoxes' | 'both';
interface FieldErrors {
name?: string;
email?: string;
details?: string;
quantity?: string;
agree?: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
declare global {
interface Window {
turnstile?: {
render: (el: string | HTMLElement, opts: Record<string, unknown>) => string;
reset: (id?: string) => void;
remove: (id: string) => void;
};
}
}
interface ColorOption {
name: string;
hex: string;
inStock: boolean;
}
interface Props {
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
turnstileSiteKey?: string;
/**
* Available filament colors for printed items, from store-products.ts. Only
* in-stock colors are offered in the color dropdown. Omit to hide the color
* selector entirely (e.g. if no colors are configured).
*/
colors?: ColorOption[];
}
export default function OrderForm({ turnstileSiteKey, colors = [] }: Props) {
const [status, setStatus] = useState<Status>('idle');
const [errors, setErrors] = useState<FieldErrors>({});
const [errorMsg, setErrorMsg] = useState<string>('');
// Tracks the order type so the color selector can show/hide. Mirrors the
// <select name="orderType"> so the two stay in sync.
const [orderType, setOrderType] = useState<OrderType>('cards');
// Colors currently offered = in-stock only. Empty when none configured.
const availableColors = colors.filter((c) => c.inStock);
const showColors = availableColors.length > 0 && (orderType === 'deckBoxes' || orderType === 'both');
const turnstileTokenRef = useRef<string>('');
const turnstileWidgetIdRef = useRef<string | null>(null);
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
// Same Turnstile bootstrap as ContactForm.
useEffect(() => {
if (!turnstileSiteKey) return;
if (window.turnstile) {
renderWidget();
return;
}
let script = document.getElementById('cf-turnstile-script') as HTMLScriptElement | null;
if (!script) {
script = document.createElement('script');
script.id = 'cf-turnstile-script';
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
script.addEventListener('load', renderWidget);
return () => {
script?.removeEventListener('load', renderWidget);
};
}, [turnstileSiteKey]);
function renderWidget() {
if (!turnstileSiteKey) return;
if (!window.turnstile || !turnstileContainerRef.current) return;
if (turnstileWidgetIdRef.current) return;
turnstileWidgetIdRef.current = window.turnstile.render(turnstileContainerRef.current, {
sitekey: turnstileSiteKey,
callback: (token: string) => {
turnstileTokenRef.current = token;
},
'expired-callback': () => {
turnstileTokenRef.current = '';
},
'error-callback': () => {
turnstileTokenRef.current = '';
},
theme: 'dark',
});
}
interface OrderValues {
name: string;
email: string;
orderType: OrderType;
color: string;
quantity: string;
details: string;
notes: string;
agree: boolean;
company: string;
turnstileToken: string;
}
function validate(v: OrderValues): FieldErrors {
const e: FieldErrors = {};
if (!v.name.trim()) e.name = 'Please enter your name.';
if (!v.email.trim()) e.email = 'Please enter your email.';
else if (!EMAIL_RE.test(v.email)) e.email = 'That email address looks off.';
if (!v.details.trim()) e.details = 'Please describe your order (card list or deck-box spec).';
else if (v.details.trim().length < 10) e.details = 'A little more detail, please (10+ characters).';
if (!v.quantity.trim()) e.quantity = 'Please enter an approximate quantity.';
if (!v.agree) e.agree = 'You must acknowledge the policies to continue.';
return e;
}
async function onSubmit(ev: FormEvent<HTMLFormElement>) {
ev.preventDefault();
const form = ev.currentTarget;
const data = new FormData(form);
const values: OrderValues = {
name: String(data.get('name') ?? '').trim(),
email: String(data.get('email') ?? '').trim(),
orderType: (String(data.get('orderType') ?? 'cards') as OrderType),
color: String(data.get('color') ?? '').trim(),
quantity: String(data.get('quantity') ?? '').trim(),
details: String(data.get('details') ?? '').trim(),
notes: String(data.get('notes') ?? '').trim(),
agree: data.get('agree') === 'on',
company: String(data.get('company') ?? ''),
turnstileToken: turnstileTokenRef.current,
};
const found = validate(values);
if (turnstileSiteKey && !values.turnstileToken) {
found.agree = found.agree ?? 'Please complete the bot check and try again.';
}
setErrors(found);
if (Object.keys(found).length > 0) return;
setStatus('submitting');
setErrorMsg('');
try {
const res = await fetch('/api/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
window.turnstile?.reset(turnstileWidgetIdRef.current);
turnstileTokenRef.current = '';
}
throw new Error(body.error || 'Something went wrong. Please try again.');
}
setStatus('success');
form.reset();
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
window.turnstile?.reset(turnstileWidgetIdRef.current);
turnstileTokenRef.current = '';
}
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
}
}
const inputBase =
'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]';
const labelBase = 'mb-1.5 block font-sans text-sm font-medium';
const fieldColor = { color: 'var(--color-text)' };
const borderFor = (k: keyof FieldErrors) => (errors[k] ? 'var(--color-accent)' : 'var(--color-border)');
if (status === 'success') {
return (
<div
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
>
<span
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
aria-hidden="true"
>
<BrandIcon name="check" size={24} />
</span>
<h3 className="font-display text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Order request sent.
</h3>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Thanks! I'll review your request and reply by email. No payment is taken through the site.
</p>
<button type="button" onClick={() => setStatus('idle')} className="btn btn-secondary mt-2">
Submit another
</button>
</div>
);
}
return (
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
{/* Honeypot */}
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
<label htmlFor="company">Company (leave blank)</label>
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="flex flex-col">
<label htmlFor="name" className={labelBase} style={fieldColor}>Name</label>
<input id="name" name="name" type="text" autoComplete="name" className={inputBase} style={{ borderColor: borderFor('name'), ...fieldColor }} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'name-error' : undefined} />
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
</div>
<div className="flex flex-col">
<label htmlFor="email" className={labelBase} style={fieldColor}>Email</label>
<input id="email" name="email" type="email" autoComplete="email" className={inputBase} style={{ borderColor: borderFor('email'), ...fieldColor }} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'email-error' : undefined} />
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
</div>
<div className="flex flex-col">
<label htmlFor="orderType" className={labelBase} style={fieldColor}>Order type</label>
<select
id="orderType"
name="orderType"
value={orderType}
onChange={(e) => setOrderType(e.target.value as OrderType)}
className={inputBase}
style={{ borderColor: 'var(--color-border)', ...fieldColor }}
>
<option value="cards">Proxy MTG cards</option>
<option value="deckBoxes">3D-printed deck box / counters</option>
<option value="both">Both</option>
</select>
</div>
{/* Color selector — only for printed items, only when colors are
configured and in stock. The list comes from store-products.ts. */}
{showColors && (
<div className="flex flex-col">
<label htmlFor="color" className={labelBase} style={fieldColor}>Print color</label>
<select id="color" name="color" defaultValue="" className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor }}>
<option value="" disabled>Select a color&hellip;</option>
{availableColors.map((c) => (
<option key={c.name} value={c.name}>{c.name}</option>
))}
</select>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
Colors shown are what&rsquo;s loaded right now. See the deck-boxes page for swatches.
</p>
</div>
)}
<div className="flex flex-col">
<label htmlFor="quantity" className={labelBase} style={fieldColor}>Approximate quantity</label>
<input id="quantity" name="quantity" type="text" placeholder="e.g. 75 cards, or 1 deck box + 4 counters" className={inputBase} style={{ borderColor: borderFor('quantity'), ...fieldColor }} aria-invalid={Boolean(errors.quantity)} aria-describedby={errors.quantity ? 'quantity-error' : undefined} />
{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>
);
}