'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { NativeSelect } from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { NumberInput } from '@/components/ui/number-input';
import { FileUpload, type UploadItem } from '@/components/ui/file-upload';
import { Checkbox } from '@/components/ui/checkbox';
import { DateInput } from '@/components/ui/date-input';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ChipGroup, checkFields, DonePanel, ErrorSummary, FieldView, FlowHead, isEmail, isPhone, summaryOf, useFlowTexts, useFocusFirstError, useFormState, type DoneStep, type FieldSpec, type FlowValue, type FlowValues } from '@/components/ui/form-flow';
/*
* Asking for a price. Each variant fits a different kind of business:
*
* brief a project brief with a budget range, deadline and files, next steps beside it
* line-items a request for quotation with product lines, quantities and a delivery date
* by-type the fields change with the kind of project, the form glides to the new set
* call-or-form write the details or ask for a call, both short, with the reply time in view
*
* Every variant checks before it sends, names what is missing above the
* form, focuses the first open field and confirms in place.
*/
export type RequestQuoteVariant = 'brief' | 'line-items' | 'by-type' | 'call-or-form';
export interface QuoteLine {
item: string;
quantity: number;
unit?: string;
}
export interface QuoteType {
value: string;
label: string;
icon?: IconName;
description?: string;
/** by-type: the extra fields for this kind */
fields: FieldSpec[];
}
export interface RequestQuoteProps {
variant?: RequestQuoteVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** brief: kinds of project to tap */
topics?: string[];
/** brief: the budget scale and a currency sign */
budget?: { min: number; max: number; step: number; currency: string; defaultValue?: [number, number] };
/** brief and call-or-form: what happens after sending */
steps?: DoneStep[];
/** brief and call-or-form: the person who answers */
contact?: { name: string; role?: string; initials?: string; reply?: string; phone?: string };
/** line-items: the units to pick per line and lines to start with */
units?: string[];
lines?: QuoteLine[];
/** by-type: kinds of project with their own fields */
types?: QuoteType[];
/** call-or-form: time windows for a call */
windows?: string[];
submitLabel?: string;
done?: { title: string; text?: string };
onSubmit?: (data: Record<string, unknown>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
topics: 'What do you need?',
topicsNeeded: 'Please choose at least one topic, so the right person answers.',
budget: 'Budget',
deadline: 'Ready by',
details: 'Tell us about the project',
detailsHint: 'Goals, audience, examples you like',
detailsNeeded: 'Please describe the project in a sentence or two, it is what we price.',
files: 'Files',
filesHint: 'Briefs, sketches or examples, PDF or images up to 20 MB',
name: 'Name',
nameNeeded: 'Please tell us your name so we know who we write to.',
company: 'Company',
email: 'Email',
emailNeeded: 'This address looks incomplete. Please use a form like name@example.com so the quote reaches you.',
phone: 'Phone',
phoneNeeded: 'Please add a number with area code, so we can call you at the time you chose.',
send: 'Request a quote',
next: 'What happens next',
item: 'Product or item number',
quantity: 'Quantity',
unit: 'Unit',
line: 'Line {n}',
addLine: 'Add a line',
removeLine: 'Remove line {n}',
linesNeeded: 'Please name at least one product, the quote is built from these lines.',
postcode: 'Delivery postcode',
neededBy: 'Needed by',
notes: 'Notes',
notesHint: 'Delivery to the loading dock, partial deliveries and the like',
totalLines: '{n} lines',
totalUnits: '{n} pieces in total',
kind: 'Kind of project',
how: 'How should we answer?',
write: 'Write to us',
call: 'Call me back',
window: 'Best time',
windowNeeded: 'Please choose a time window, so we call when it suits you.',
question: 'What is it about?',
reply: 'Reply within {t}',
consent: 'I agree that my details are used to prepare this quote.',
consentNeeded: 'Please agree so we are allowed to use your details for the quote.',
doneTitle: 'Thank you, your request is with us',
doneText: 'You get the quote by email, usually within one working day.',
callDone: 'We call you {w}',
again: 'Start a new request',
},
de: {
topics: 'Was brauchst du?',
topicsNeeded: 'Bitte wähle mindestens ein Thema, damit die richtige Person antwortet.',
budget: 'Budget',
deadline: 'Fertig bis',
details: 'Erzähl uns vom Projekt',
detailsHint: 'Ziele, Zielgruppe, Beispiele, die dir gefallen',
detailsNeeded: 'Bitte beschreib das Projekt in ein, zwei Sätzen, danach richtet sich der Preis.',
files: 'Dateien',
filesHint: 'Briefings, Skizzen oder Beispiele, PDF oder Bilder bis 20 MB',
name: 'Name',
nameNeeded: 'Bitte nenne uns deinen Namen, damit wir wissen, wem wir schreiben.',
company: 'Firma',
email: 'E-Mail',
emailNeeded: 'Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit das Angebot dich erreicht.',
phone: 'Telefon',
phoneNeeded: 'Bitte gib eine Nummer mit Vorwahl an, damit wir dich zur gewählten Zeit anrufen können.',
send: 'Angebot anfordern',
next: 'So geht es weiter',
item: 'Produkt oder Artikelnummer',
quantity: 'Menge',
unit: 'Einheit',
line: 'Position {n}',
addLine: 'Position hinzufügen',
removeLine: 'Position {n} entfernen',
linesNeeded: 'Bitte nenne mindestens ein Produkt, das Angebot entsteht aus diesen Positionen.',
postcode: 'Lieferpostleitzahl',
neededBy: 'Benötigt bis',
notes: 'Hinweise',
notesHint: 'Lieferung an die Rampe, Teillieferungen und Ähnliches',
totalLines: '{n} Positionen',
totalUnits: '{n} Stück insgesamt',
kind: 'Art des Projekts',
how: 'Wie sollen wir antworten?',
write: 'Schreiben',
call: 'Rückruf',
window: 'Beste Zeit',
windowNeeded: 'Bitte wähle ein Zeitfenster, damit wir anrufen, wenn es dir passt.',
question: 'Worum geht es?',
reply: 'Antwort innerhalb von {t}',
consent: 'Ich bin einverstanden, dass meine Angaben für dieses Angebot genutzt werden.',
consentNeeded: 'Bitte stimme zu, damit wir deine Angaben für das Angebot nutzen dürfen.',
doneTitle: 'Danke, deine Anfrage ist bei uns',
doneText: 'Das Angebot kommt per E-Mail, meist innerhalb eines Werktags.',
callDone: 'Wir rufen dich an, {w}',
again: 'Neue Anfrage starten',
},
};
type Texts = (typeof TEXTS)['en'];
const fmtMoney = (n: number, currency: string, locale: 'en' | 'de') => {
const num = n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
return locale === 'de' ? `${num} ${currency}` : `${currency}${num}`;
};
function Done({ p, t, onAgain, title, text, className }: { p: RequestQuoteProps; t: Texts; onAgain: () => void; title?: string; text?: string; className?: string }) {
return (
<div className={cn('kit-card p-6 sm:p-10', className)}>
<DonePanel
title={title ?? p.done?.title ?? t.doneTitle}
text={text ?? p.done?.text ?? t.doneText}
steps={p.steps}
actions={
<Button variant="outline" iconLeft="rotate-ccw" onClick={onAgain}>
{t.again}
</Button>
}
/>
</div>
);
}
function Person({ c, t, className }: { c: NonNullable<RequestQuoteProps['contact']>; t: Texts; className?: string }) {
const initials = c.initials ?? c.name.split(' ').map((s) => s[0]).join('').slice(0, 2);
return (
<div className={cn('flex items-center gap-3.5', className)}>
<span className="relative grid size-12 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[15px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
{initials}
<span className="absolute -bottom-0.5 -end-0.5 size-3.5 rounded-full border-2 border-background bg-success" />
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold text-foreground">{c.name}</span>
{c.role && <span className="block text-[13px] text-muted-foreground">{c.role}</span>}
{c.reply && <span className="mt-0.5 block text-[13px] text-success-foreground">{t.reply.replace('{t}', c.reply)}</span>}
</span>
</div>
);
}
/** The steps after sending as a rail that fills while it scrolls into view */
function NextSteps({ steps, t }: { steps: DoneStep[]; t: Texts }) {
return (
<div>
<p className="text-[13px] font-medium text-muted-foreground">{t.next}</p>
<Stagger step={110} as="ol" className="relative mt-4 grid gap-5">
{steps.map((s, i) => (
<li key={s.title} className="relative flex gap-4">
{i < steps.length - 1 && <span aria-hidden className="absolute start-[19px] top-11 h-[calc(100%-1.5rem)] w-px bg-border" />}
<span className="grid size-10 shrink-0 place-items-center rounded-xl border border-border bg-card text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))] shadow-[var(--shadow-sm)]">
{s.icon ? <Icon name={s.icon} size={18} /> : <span className="text-[14px] font-semibold tabular-nums">{i + 1}</span>}
</span>
<span className="min-w-0 pt-1">
<span className="block text-[15px] font-medium text-foreground">{s.title}</span>
{s.text && <span className="mt-0.5 block text-pretty text-[14px] leading-relaxed text-muted-foreground">{s.text}</span>}
</span>
</li>
))}
</Stagger>
</div>
);
}
/* ------------------------------------------------------------------ */
/* brief */
/* ------------------------------------------------------------------ */
function Brief({ p, t }: { p: RequestQuoteProps; t: Texts }) {
const id = useId();
const locale = useLocale();
const q = useFormState();
const b = p.budget ?? { min: 2000, max: 40000, step: 1000, currency: '€', defaultValue: [8000, 16000] as [number, number] };
const [topics, setTopics] = useState<string[]>([]);
const [budget, setBudget] = useState<[number, number]>(b.defaultValue ?? [b.min, b.max]);
const [deadline, setDeadline] = useState('');
const [details, setDetails] = useState('');
const [files, setFiles] = useState<UploadItem[]>([]);
const [who, setWho] = useState({ name: '', company: '', email: '' });
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, q.attempt, summaryRef);
const summary = summaryOf(q.errors, [
['topics', `${id}-topics`, t.topics],
['details', `${id}-details`, t.details],
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
]);
const form = q.done ? (
<Done p={p} t={t} onAgain={() => (setTopics([]), setDetails(''), setFiles([]), q.setDone(false))} />
) : (
<form
ref={formRef}
noValidate
className="kit-card grid grid-cols-1 gap-6 p-6 sm:grid-cols-2 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!topics.length) found.topics = t.topicsNeeded;
if (!details.trim()) found.details = t.detailsNeeded;
if (!who.name.trim()) found.name = t.nameNeeded;
if (!isEmail(who.email)) found.email = t.emailNeeded;
if (q.fail(found)) return;
p.onSubmit?.({ topics, budget, deadline, details, files: files.map((f) => f.name), ...who });
q.setDone(true);
}}
>
<ErrorSummary ref={summaryRef} errors={summary} className="sm:col-span-2" />
<ChipGroup id={`${id}-topics`} legend={t.topics} required multiple options={(p.topics ?? []).map((x) => ({ value: x, label: x }))} value={topics} onChange={(v) => (setTopics(v), q.clear('topics'))} error={q.errors.topics} className="sm:col-span-2" />
<Slider range label={t.budget} min={b.min} max={b.max} step={b.step} value={budget} onValueChange={setBudget} format={(v) => fmtMoney(v, b.currency, locale)} className="sm:col-span-2" />
<DateInput label={t.deadline} optional value={deadline || null} onValueChange={(v) => setDeadline(v ?? '')} />
<div className="hidden sm:block" />
<Textarea id={`${id}-details`} label={t.details} hint={q.errors.details ? undefined : t.detailsHint} required value={details} onChange={(e) => (setDetails(e.target.value), q.clear('details'))} error={q.errors.details} minRows={4} maxLength={2000} className="sm:col-span-2" />
<FileUpload
label={t.files}
hint={t.filesHint}
accept=".pdf,image/*"
maxSize={20e6}
items={files}
onFiles={(list) => setFiles((x) => [...x, ...list.map((f, i) => ({ id: `${f.name}-${x.length + i}`, name: f.name, size: f.size, status: 'done' as const }))])}
onRemove={(fid) => setFiles((x) => x.filter((f) => f.id !== fid))}
className="sm:col-span-2"
/>
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={who.name} onChange={(e) => (setWho((w) => ({ ...w, name: e.target.value })), q.clear('name'))} error={q.errors.name} />
<Input label={t.company} optional autoComplete="organization" value={who.company} onChange={(e) => setWho((w) => ({ ...w, company: e.target.value }))} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={who.email} onChange={(e) => (setWho((w) => ({ ...w, email: e.target.value })), q.clear('email'))} error={q.errors.email} className="sm:col-span-2" />
<div className="border-t border-border pt-6 sm:col-span-2">
<Button type="submit" icon="send" magnetic className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
);
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-16">
<div className="lg:sticky lg:top-24 lg:self-start">
<FlowHead eyebrow={p.eyebrow} title={p.title} description={p.description} />
{p.contact && <Person c={p.contact} t={t} className="mt-8" />}
{/* Desktop shows the next steps here, phones and tablets after the form */}
{p.steps && (
<div className="mt-10 hidden border-t border-border pt-8 lg:block">
<NextSteps steps={p.steps} t={t} />
</div>
)}
</div>
<div className="min-w-0">
<Reveal effect="rise">{form}</Reveal>
{p.steps && !q.done && (
<div className="mt-10 lg:hidden">
<NextSteps steps={p.steps} t={t} />
</div>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* line-items */
/* ------------------------------------------------------------------ */
function LineItems({ p, t }: { p: RequestQuoteProps; t: Texts }) {
const id = useId();
const locale = useLocale();
const q = useFormState();
const units = p.units ?? ['pcs'];
const [lines, setLines] = useState<QuoteLine[]>(p.lines?.length ? p.lines : [{ item: '', quantity: 1, unit: units[0] }]);
const [postcode, setPostcode] = useState('');
const [neededBy, setNeededBy] = useState('');
const [notes, setNotes] = useState('');
const [who, setWho] = useState({ company: '', name: '', email: '' });
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
const added = useRef(false);
useFocusFirstError(formRef, q.attempt, summaryRef);
useEffect(() => {
if (!added.current) return;
added.current = false;
document.getElementById(`${id}-l${lines.length - 1}`)?.focus();
}, [lines.length, id]);
const filled = lines.filter((l) => l.item.trim());
const pieces = filled.reduce((s, l) => s + (Number.isFinite(l.quantity) ? l.quantity : 0), 0);
const nf = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
const summary = summaryOf(q.errors, [
['lines', `${id}-l0`, t.item],
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
]);
const set = (i: number, patch: Partial<QuoteLine>) => setLines((x) => x.map((l, j) => (j === i ? { ...l, ...patch } : l)));
if (q.done) return <Done p={p} t={t} onAgain={() => (setLines([{ item: '', quantity: 1, unit: units[0] }]), q.setDone(false))} className="mx-auto max-w-[56rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[60rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!filled.length) found.lines = t.linesNeeded;
if (!who.name.trim()) found.name = t.nameNeeded;
if (!isEmail(who.email)) found.email = t.emailNeeded;
if (q.fail(found)) return;
p.onSubmit?.({ lines: filled, postcode, neededBy, notes, ...who });
q.setDone(true);
}}
>
<div className="grid gap-6 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<fieldset className="min-w-0">
<legend className="text-[15px] font-semibold text-foreground">{t.item}</legend>
{/* Column heads from tablet up, each line is a row; on phones each line is a small card */}
<div aria-hidden className="mt-4 hidden grid-cols-[2rem_minmax(0,1fr)_9.5rem_8rem_2.75rem] gap-3 px-1 text-[12px] font-medium text-muted-foreground sm:grid">
<span>#</span>
<span>{t.item}</span>
<span>{t.quantity}</span>
<span>{t.unit}</span>
<span />
</div>
<ol className="mt-2 grid gap-2">
{lines.map((l, i) => (
<li key={i} className="kit-rise grid grid-cols-[minmax(0,1fr)_2.75rem] items-start gap-2 rounded-xl border border-border bg-surface p-3 sm:grid-cols-[2rem_minmax(0,1fr)_9.5rem_8rem_2.75rem] sm:items-center sm:gap-3 sm:border-0 sm:bg-transparent sm:p-0 sm:px-1">
<span aria-hidden className="hidden text-[13px] font-semibold tabular-nums text-subtle-foreground sm:block">
{i + 1}
</span>
<Input
id={`${id}-l${i}`}
aria-label={`${t.line.replace('{n}', String(i + 1))}, ${t.item}`}
placeholder={t.item}
value={l.item}
invalid={i === 0 && Boolean(q.errors.lines)}
aria-describedby={i === 0 && q.errors.lines ? `${id}-le` : undefined}
onChange={(e) => (set(i, { item: e.target.value }), q.clear('lines'))}
/>
<button type="button" disabled={lines.length < 2} aria-label={t.removeLine.replace('{n}', String(i + 1))} onClick={() => setLines((x) => x.filter((_, j) => j !== i))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none disabled:opacity-30 sm:order-last">
<Icon name="trash" size={17} />
</button>
<div className="col-span-2 grid grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] gap-2 sm:col-span-1 sm:contents">
<NumberInput aria-label={`${t.line.replace('{n}', String(i + 1))}, ${t.quantity}`} variant="stepper" min={1} max={100000} value={l.quantity} onValueChange={(v) => set(i, { quantity: v })} />
<NativeSelect aria-label={`${t.line.replace('{n}', String(i + 1))}, ${t.unit}`} options={units.map((u) => ({ value: u, label: u }))} value={l.unit ?? units[0]} onChange={(e) => set(i, { unit: e.target.value })} />
</div>
</li>
))}
</ol>
{q.errors.lines && (
<p id={`${id}-le`} className="kit-rise mt-2 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{q.errors.lines}
</p>
)}
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<Button
type="button"
variant="outline"
size="sm"
iconLeft="plus"
onClick={() => {
added.current = true;
setLines((x) => [...x, { item: '', quantity: 1, unit: units[0] }]);
}}
>
{t.addLine}
</Button>
<p aria-live="polite" className="text-[13px] tabular-nums text-muted-foreground">
{t.totalLines.replace('{n}', nf(filled.length))} · {t.totalUnits.replace('{n}', nf(pieces))}
</p>
</div>
</fieldset>
<div className="grid grid-cols-1 gap-5 border-t border-border pt-6 sm:grid-cols-2">
<Input label={t.postcode} optional autoComplete="postal-code" value={postcode} onChange={(e) => setPostcode(e.target.value)} />
<DateInput label={t.neededBy} optional value={neededBy || null} onValueChange={(v) => setNeededBy(v ?? '')} />
<Textarea label={t.notes} optional hint={t.notesHint} value={notes} onChange={(e) => setNotes(e.target.value)} minRows={2} className="sm:col-span-2" />
</div>
<div className="grid grid-cols-1 gap-5 border-t border-border pt-6 sm:grid-cols-3">
<Input label={t.company} optional autoComplete="organization" value={who.company} onChange={(e) => setWho((w) => ({ ...w, company: e.target.value }))} />
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={who.name} onChange={(e) => (setWho((w) => ({ ...w, name: e.target.value })), q.clear('name'))} error={q.errors.name} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={who.email} onChange={(e) => (setWho((w) => ({ ...w, email: e.target.value })), q.clear('email'))} error={q.errors.email} />
</div>
</div>
<div className="flex flex-col gap-3 border-t border-border bg-surface px-6 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p className="text-[13px] text-muted-foreground">{p.done?.text ?? t.doneText}</p>
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* by-type */
/* ------------------------------------------------------------------ */
function ByType({ p, t }: { p: RequestQuoteProps; t: Texts }) {
const id = useId();
const ft = useFlowTexts();
const q = useFormState();
const types = p.types ?? [];
const [type, setType] = useState(types[0]?.value ?? '');
const [values, setValues] = useState<FlowValues>({});
const [who, setWho] = useState({ name: '', email: '' });
const [dir, setDir] = useState(1);
const fieldsRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const first = useRef(true);
useFocusFirstError(formRef, q.attempt);
const current = types.find((x) => x.value === type) ?? types[0];
const index = types.findIndex((x) => x.value === type);
// The new set of fields glides in from the side of the chosen tab
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const el = fieldsRef.current;
if (!el || typeof el.animate !== 'function' || prefersReducedMotion()) return;
el.animate([{ opacity: 0, transform: `translateX(${dir * 20}px)` }, { opacity: 1, transform: 'translateX(0)' }], { duration: 420, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}, [type, dir]);
const pick = (v: string) => {
setDir(types.findIndex((x) => x.value === v) >= index ? 1 : -1);
setType(v);
q.fail({});
};
if (q.done) return <Done p={p} t={t} onAgain={() => (setValues({}), q.setDone(false))} className="mx-auto max-w-[52rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[52rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found = checkFields(current?.fields ?? [], values, ft);
if (!who.name.trim()) found.__name = t.nameNeeded;
if (!isEmail(who.email)) found.__email = t.emailNeeded;
if (q.fail(found)) return;
p.onSubmit?.({ type, ...values, ...who });
q.setDone(true);
}}
>
{/* Kinds of project as tabs with a sliding marker, arrow keys move between them */}
<div role="radiogroup" aria-label={t.kind} className="relative grid border-b border-border bg-surface" style={{ gridTemplateColumns: `repeat(${types.length}, minmax(0, 1fr))` }}>
<span aria-hidden className="absolute bottom-0 start-0 h-0.5 bg-primary transition-[translate] duration-500 ease-[cubic-bezier(0.3,1.2,0.5,1)] motion-reduce:transition-none" style={{ width: `${round(100 / Math.max(1, types.length), 4)}%`, translate: `${Math.max(0, index) * 100}% 0` }} />
{types.map((x, i) => (
<label key={x.value} htmlFor={`${id}-t${i}`} className="group/t flex min-h-16 cursor-pointer flex-col items-center justify-center gap-1 px-2 py-3 text-center transition-colors has-[:focus-visible]:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] sm:flex-row sm:gap-2.5">
<input id={`${id}-t${i}`} type="radio" name={`${id}-type`} value={x.value} checked={type === x.value} onChange={() => pick(x.value)} className="sr-only" />
{x.icon && <Icon name={x.icon} size={18} className={cn('transition-colors', type === x.value ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-muted-foreground')} />}
<span className={cn('text-[13px] font-medium leading-tight sm:text-[14px]', type === x.value ? 'text-foreground' : 'text-muted-foreground group-hover/t:text-foreground')}>{x.label}</span>
</label>
))}
</div>
<div className="p-6 sm:p-8">
<div ref={fieldsRef} key={type}>
{current?.description && <p className="mb-6 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{current.description}</p>}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{current?.fields.map((f) => (
<FieldView key={`${type}-${f.name}`} f={f} id={`${id}-${type}-${f.name}`} value={values[f.name]} onChange={(v: FlowValue) => (setValues((x) => ({ ...x, [f.name]: v })), q.clear(f.name))} error={q.errors[f.name]} />
))}
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-5 border-t border-border pt-6 sm:grid-cols-2">
<Input label={t.name} required autoComplete="name" value={who.name} onChange={(e) => (setWho((w) => ({ ...w, name: e.target.value })), q.clear('__name'))} error={q.errors.__name} />
<Input type="email" label={t.email} required autoComplete="email" value={who.email} onChange={(e) => (setWho((w) => ({ ...w, email: e.target.value })), q.clear('__email'))} error={q.errors.__email} />
</div>
<div className="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[13px] text-muted-foreground max-sm:text-center">{p.done?.text ?? t.doneText}</p>
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* call-or-form */
/* ------------------------------------------------------------------ */
function CallOrForm({ p, t }: { p: RequestQuoteProps; t: Texts }) {
const id = useId();
const q = useFormState();
const [mode, setMode] = useState<'write' | 'call'>('write');
const [v, setV] = useState({ question: '', name: '', email: '', phone: '' });
const [windowPick, setWindowPick] = useState<string[]>([]);
const [consent, setConsent] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
useFocusFirstError(formRef, q.attempt);
const set = (k: keyof typeof v) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => (setV((x) => ({ ...x, [k]: e.target.value })), q.clear(k));
const windows = p.windows ?? [];
if (q.done)
return (
<Done
p={p}
t={t}
onAgain={() => (setV({ question: '', name: '', email: '', phone: '' }), setConsent(false), q.setDone(false))}
title={mode === 'call' && windowPick[0] ? t.callDone.replace('{w}', windowPick[0]) : undefined}
className="mx-auto max-w-[46rem]"
/>
);
return (
<div className="mx-auto grid max-w-[64rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_19rem] lg:gap-8">
<Reveal effect="rise" className="min-w-0">
<form
ref={formRef}
noValidate
className="kit-card p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!v.name.trim()) found.name = t.nameNeeded;
if (mode === 'write') {
if (!v.question.trim()) found.question = t.detailsNeeded;
if (!isEmail(v.email)) found.email = t.emailNeeded;
} else {
if (!isPhone(v.phone)) found.phone = t.phoneNeeded;
if (!windowPick.length) found.window = t.windowNeeded;
}
if (!consent) found.consent = t.consentNeeded;
if (q.fail(found)) return;
p.onSubmit?.({ mode, ...v, window: windowPick[0] });
q.setDone(true);
}}
>
{/* Two ways in, a switch with a sliding tile */}
<div role="radiogroup" aria-label={t.how} className="relative grid grid-cols-2 rounded-xl bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1">
<span aria-hidden className={cn('absolute inset-y-1 start-1 w-[calc(50%-0.25rem)] rounded-lg bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none', mode === 'call' && 'translate-x-full rtl:-translate-x-full')} />
{(['write', 'call'] as const).map((m) => (
<label key={m} htmlFor={`${id}-${m}`} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-lg text-[14px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
<input id={`${id}-${m}`} type="radio" name={`${id}-mode`} value={m} checked={mode === m} onChange={() => (setMode(m), q.fail({}))} className="sr-only" />
<Icon name={m === 'write' ? 'mail' : 'phone-call'} size={16} />
{m === 'write' ? t.write : t.call}
</label>
))}
</div>
<div key={mode} className="kit-rise mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
{mode === 'write' ? (
<>
<Textarea label={t.question} required value={v.question} onChange={set('question')} error={q.errors.question} minRows={4} maxLength={1500} className="sm:col-span-2" />
<Input label={t.name} required autoComplete="name" value={v.name} onChange={set('name')} error={q.errors.name} />
<Input type="email" label={t.email} required autoComplete="email" value={v.email} onChange={set('email')} error={q.errors.email} />
</>
) : (
<>
<Input label={t.name} required autoComplete="name" value={v.name} onChange={set('name')} error={q.errors.name} />
<Input type="tel" inputMode="tel" label={t.phone} required autoComplete="tel" value={v.phone} onChange={set('phone')} error={q.errors.phone} />
<ChipGroup legend={t.window} required options={windows.map((w) => ({ value: w, label: w }))} value={windowPick} onChange={(x) => (setWindowPick(x), q.clear('window'))} error={q.errors.window} layout={windows.length >= 4 ? 2 : 'wrap'} className="sm:col-span-2" />
</>
)}
<div className="sm:col-span-2">
<Checkbox label={t.consent} checked={consent} onChange={(e) => (setConsent(e.target.checked), q.clear('consent'))} error={q.errors.consent} />
</div>
</div>
<div className="mt-7 border-t border-border pt-6">
<Button type="submit" icon={mode === 'write' ? 'send' : 'phone-call'} className="max-sm:w-full">
{mode === 'write' ? (p.submitLabel ?? t.send) : t.call}
</Button>
</div>
</form>
</Reveal>
<Reveal effect="rise" delay={120}>
<aside className="grid gap-6 rounded-2xl border border-border bg-surface p-6">
{p.contact && <Person c={p.contact} t={t} />}
{p.contact?.phone && (
<a href={`tel:${p.contact.phone.replace(/[^\d+]/g, '')}`} className="flex min-h-11 items-center gap-2.5 text-[15px] font-semibold tabular-nums text-foreground underline-offset-4 hover:underline">
<Icon name="phone" size={17} className="text-muted-foreground" />
{p.contact.phone}
</a>
)}
{p.steps && (
<div className="border-t border-border pt-6">
<NextSteps steps={p.steps} t={t} />
</div>
)}
</aside>
</Reveal>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* A request for a quote.
* <RequestQuote variant="brief" topics={['Website', 'Branding']} budget={{ min: 2000, max: 40000, step: 1000, currency: '€' }} steps={[…]} />
* <RequestQuote variant="line-items" units={['pcs', 'boxes']} />
*/
export function RequestQuote(props: RequestQuoteProps) {
const { variant = 'brief', labels, className } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
{variant !== 'brief' && <FlowHead eyebrow={props.eyebrow} title={props.title} description={props.description} align="center" className="mb-10 sm:mb-14" />}
{variant === 'brief' && <Brief p={props} t={t} />}
{variant === 'line-items' && <LineItems p={props} t={t} />}
{variant === 'by-type' && <ByType p={props} t={t} />}
{variant === 'call-or-form' && <CallOrForm p={props} t={t} />}
</Section>
);
}