Skip to content
UI Kit
Accent color

Request a quote

Asking for a price, shaped for the business. A project brief with budget range and files, product lines for wholesale, fields that change with the kind of job, or the choice between writing and a call.

One of 4 components in Request forms

4 variants

Variants

Project brief

Topics to tap, a budget range with two handles, deadline, files and contact. Beside it the person who answers and what happens next, on a rail whose steps rise in one after another.

Open in Studio

Product lines

A request for quotation as product lines with quantity and unit, lines are added and removed, the totals count along. From tablet up a table with column heads, on phones every line is a small card.

Open in Studio

Fields by type

Kinds of job as tabs with a sliding marker. Each kind brings its own fields, which glide in from the side of the chosen tab. Answers of a kind stay when switching back.

Open in Studio

Write or call

Two short ways in on a switch with a sliding tile, the fields change with the way. Beside it the person who answers, the number to call right now and what happens next.

Open in Studio

Details

What Request a quote brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/request-quote.tsx
npm packages
None
Languages
English and German, 45 texts of its own

Props

The RequestQuoteProps interface from components/ui/request-quote.tsx, with the comments from the code.

  • variantoptional
    RequestQuoteVariant

    briefline-itemsby-typecall-or-form

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • topicsoptional
    string[]

    brief: kinds of project to tap

  • budgetoptional
    { min: number; max: number; step: number; currency: string; defaultValue?: [number, number] }

    brief: the budget scale and a currency sign

  • stepsoptional
    DoneStep[]

    brief and call-or-form: what happens after sending

  • contactoptional
    { name: string; role?: string; initials?: string; reply?: string; phone?: string }

    brief and call-or-form: the person who answers

  • unitsoptional
    string[]

    line-items: the units to pick per line and lines to start with

  • linesoptional
    QuoteLine[]
  • typesoptional
    QuoteType[]

    by-type: kinds of project with their own fields

  • windowsoptional
    string[]

    call-or-form: time windows for a call

  • submitLabeloptional
    string
  • doneoptional
    { title: string; text?: string }
  • onSubmitoptional
    (data: Record<string, unknown>) => void

Built-in texts

Request a quote writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 45 texts
topicsEnglish What do you need?German Was brauchst du?
topicsNeededEnglish Please choose at least one topic, so the right person answers.German Bitte wähle mindestens ein Thema, damit die richtige Person antwortet.
budgetEnglish BudgetGerman Budget
deadlineEnglish Ready byGerman Fertig bis
detailsEnglish Tell us about the projectGerman Erzähl uns vom Projekt
detailsHintEnglish Goals, audience, examples you likeGerman Ziele, Zielgruppe, Beispiele, die dir gefallen
detailsNeededEnglish Please describe the project in a sentence or two, it is what we price.German Bitte beschreib das Projekt in ein, zwei Sätzen, danach richtet sich der Preis.
filesEnglish FilesGerman Dateien
filesHintEnglish Briefs, sketches or examples, PDF or images up to 20 MBGerman Briefings, Skizzen oder Beispiele, PDF oder Bilder bis 20 MB
nameEnglish NameGerman Name
nameNeededEnglish Please tell us your name so we know who we write to.German Bitte nenne uns deinen Namen, damit wir wissen, wem wir schreiben.
companyEnglish CompanyGerman Firma
emailEnglish EmailGerman E-Mail
emailNeededEnglish This address looks incomplete. Please use a form like name@example.com so the quote reaches you.German Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit das Angebot dich erreicht.
phoneEnglish PhoneGerman Telefon
phoneNeededEnglish Please add a number with area code, so we can call you at the time you chose.German Bitte gib eine Nummer mit Vorwahl an, damit wir dich zur gewählten Zeit anrufen können.
sendEnglish Request a quoteGerman Angebot anfordern
nextEnglish What happens nextGerman So geht es weiter
itemEnglish Product or item numberGerman Produkt oder Artikelnummer
quantityEnglish QuantityGerman Menge
unitEnglish UnitGerman Einheit
lineEnglish Line {n}German Position {n}
addLineEnglish Add a lineGerman Position hinzufügen
removeLineEnglish Remove line {n}German Position {n} entfernen
linesNeededEnglish Please name at least one product, the quote is built from these lines.German Bitte nenne mindestens ein Produkt, das Angebot entsteht aus diesen Positionen.
postcodeEnglish Delivery postcodeGerman Lieferpostleitzahl
neededByEnglish Needed byGerman Benötigt bis
notesEnglish NotesGerman Hinweise
notesHintEnglish Delivery to the loading dock, partial deliveries and the likeGerman Lieferung an die Rampe, Teillieferungen und Ähnliches
totalLinesEnglish {n} linesGerman {n} Positionen
totalUnitsEnglish {n} pieces in totalGerman {n} Stück insgesamt
kindEnglish Kind of projectGerman Art des Projekts
howEnglish How should we answer?German Wie sollen wir antworten?
writeEnglish Write to usGerman Schreiben
callEnglish Call me backGerman Rückruf
windowEnglish Best timeGerman Beste Zeit
windowNeededEnglish Please choose a time window, so we call when it suits you.German Bitte wähle ein Zeitfenster, damit wir anrufen, wenn es dir passt.
questionEnglish What is it about?German Worum geht es?
replyEnglish Reply within {t}German Antwort innerhalb von {t}
consentEnglish I agree that my details are used to prepare this quote.German Ich bin einverstanden, dass meine Angaben für dieses Angebot genutzt werden.
consentNeededEnglish Please agree so we are allowed to use your details for the quote.German Bitte stimme zu, damit wir deine Angaben für das Angebot nutzen dürfen.
doneTitleEnglish Thank you, your request is with usGerman Danke, deine Anfrage ist bei uns
doneTextEnglish You get the quote by email, usually within one working day.German Das Angebot kommt per E-Mail, meist innerhalb eines Werktags.
callDoneEnglish We call you {w}German Wir rufen dich an, {w}
againEnglish Start a new requestGerman Neue Anfrage starten

Accessibility

Read from the source of the component.

  • Sets aria-describedby, aria-label and aria-live, so screen readers announce purpose and state.
  • Uses the role radiogroup.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 680 lines
components/ui/request-quote.tsx
'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>
  );
}

Frequent questions

How do I install Request a quote?

With aiw add @ai-website/request-quote. The command puts components/ui/request-quote.tsx into your project, the code is yours from then on. It brings along button, icon, input, textarea, select, slider, number-input, file-upload, checkbox, date-input, motion, reveal, section and form-flow. It needs no further npm packages.

Which variants does Request a quote have?

4 variants, Project brief, Product lines, Fields by type and Write or call. You pick one with the variant prop, for example variant="line-items".

Can I translate the texts of Request a quote?

Yes. Request a quote carries 45 texts of its own in English and German, such as “What do you need?”, “Please choose at least one topic, so the right person answers.” and “Budget”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Request a quote?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Request a quote with aiw_components and install it with aiw_add @ai-website/request-quote. Name the variant in your prompt, for example variant="brief", and the assistant writes your content straight into the props.

Can I adjust Request a quote without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching RequestQuote code to copy.

Often searched

Jump to

Copied to the clipboard