mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
01431468e4
- Palette: Patiun Teal, Midnight, night sky, Starlight, flat Alchemist Gold; violet/indigo only as nebula light. Starlight content sections, night-sky nav and heroes (.on-sky scope re-points accent/gold to on-dark values). - Type: Cinzel for headings, names and card titles; Montserrat for body, UI and eyebrows (font-eyebrow); JetBrains Mono kept for code-like blocks. - Header lockup: Patiun glyph + Cinzel "GREG KILMER" + "Web Development · a Patiun LLC company"; glyph also marks the footer and store nav. - Teal filled buttons with Midnight text; gold eyebrows; small links use a darker teal so they pass WCAG AA on Starlight. - Favicons, app icons and web manifest from the GregKilmer.com brand package. - docs/THEMING.md updated for the new palette and fonts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
350 lines
15 KiB
TypeScript
350 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';
|
|
|
|
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"
|
|
>
|
|
<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-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…</option>
|
|
{availableColors.map((c) => (
|
|
<option key={c.name} value={c.name}>{c.name}</option>
|
|
))}
|
|
</select>
|
|
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
|
Colors shown are what’s loaded right now. See the deck-boxes page for swatches.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col">
|
|
<label htmlFor="quantity" className={labelBase} style={fieldColor}>Approximate quantity</label>
|
|
<input id="quantity" name="quantity" type="text" placeholder="e.g. 75 cards, or 1 deck box + 4 counters" className={inputBase} style={{ borderColor: borderFor('quantity'), ...fieldColor }} aria-invalid={Boolean(errors.quantity)} aria-describedby={errors.quantity ? 'quantity-error' : undefined} />
|
|
{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>
|
|
);
|
|
}
|