Zum Inhalt springen
UI Kit
Accent

Angebotsanfrage

Nach einem Preis fragen, passend zum Geschäft. Ein Projektbriefing mit Budgetspanne und Dateien, Positionen für den Großhandel, Felder, die sich mit der Art des Auftrags ändern, oder die Wahl zwischen Schreiben und Rückruf.

Eine von 4 Components in Anfrageformulare

4 Variants

Variants

Projektbriefing

Themen zum Antippen, eine Budgetspanne mit zwei Griffen, Termin, Dateien und Kontakt. Daneben die Person, die antwortet, und was danach passiert, auf einer Schiene, deren Schritte nacheinander aufsteigen.

Im Studio öffnen

Positionen

Eine Preisanfrage als Positionen mit Menge und Einheit, Zeilen kommen hinzu und gehen weg, die Summen zählen mit. Ab Tablet eine Tabelle mit Spaltenköpfen, auf dem Telefon ist jede Position eine kleine Karte.

Im Studio öffnen

Felder nach Art

Arten von Aufträgen als Reiter mit gleitender Markierung. Jede Art bringt eigene Felder, die von der Seite des gewählten Reiters hereingleiten. Antworten einer Art bleiben beim Zurückwechseln.

Im Studio öffnen

Schreiben oder Rückruf

Zwei kurze Wege auf einem Schalter mit gleitender Kachel, die Felder wechseln mit dem Weg. Daneben die Person, die antwortet, die Nummer für sofort und was danach passiert.

Im Studio öffnen

Details

Was Angebotsanfrage mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/request-quote.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 45 eigene Texte

Props

Die Schnittstelle RequestQuoteProps aus components/ui/request-quote.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Angebotsanfrage selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 45 Texte anzeigen
topicsEnglisch What do you need?Deutsch Was brauchst du?
topicsNeededEnglisch Please choose at least one topic, so the right person answers.Deutsch Bitte wähle mindestens ein Thema, damit die richtige Person antwortet.
budgetEnglisch BudgetDeutsch Budget
deadlineEnglisch Ready byDeutsch Fertig bis
detailsEnglisch Tell us about the projectDeutsch Erzähl uns vom Projekt
detailsHintEnglisch Goals, audience, examples you likeDeutsch Ziele, Zielgruppe, Beispiele, die dir gefallen
detailsNeededEnglisch Please describe the project in a sentence or two, it is what we price.Deutsch Bitte beschreib das Projekt in ein, zwei Sätzen, danach richtet sich der Preis.
filesEnglisch FilesDeutsch Dateien
filesHintEnglisch Briefs, sketches or examples, PDF or images up to 20 MBDeutsch Briefings, Skizzen oder Beispiele, PDF oder Bilder bis 20 MB
nameEnglisch NameDeutsch Name
nameNeededEnglisch Please tell us your name so we know who we write to.Deutsch Bitte nenne uns deinen Namen, damit wir wissen, wem wir schreiben.
companyEnglisch CompanyDeutsch Firma
emailEnglisch EmailDeutsch E-Mail
emailNeededEnglisch This address looks incomplete. Please use a form like name@example.com so the quote reaches you.Deutsch Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit das Angebot dich erreicht.
phoneEnglisch PhoneDeutsch Telefon
phoneNeededEnglisch Please add a number with area code, so we can call you at the time you chose.Deutsch Bitte gib eine Nummer mit Vorwahl an, damit wir dich zur gewählten Zeit anrufen können.
sendEnglisch Request a quoteDeutsch Angebot anfordern
nextEnglisch What happens nextDeutsch So geht es weiter
itemEnglisch Product or item numberDeutsch Produkt oder Artikelnummer
quantityEnglisch QuantityDeutsch Menge
unitEnglisch UnitDeutsch Einheit
lineEnglisch Line {n}Deutsch Position {n}
addLineEnglisch Add a lineDeutsch Position hinzufügen
removeLineEnglisch Remove line {n}Deutsch Position {n} entfernen
linesNeededEnglisch Please name at least one product, the quote is built from these lines.Deutsch Bitte nenne mindestens ein Produkt, das Angebot entsteht aus diesen Positionen.
postcodeEnglisch Delivery postcodeDeutsch Lieferpostleitzahl
neededByEnglisch Needed byDeutsch Benötigt bis
notesEnglisch NotesDeutsch Hinweise
notesHintEnglisch Delivery to the loading dock, partial deliveries and the likeDeutsch Lieferung an die Rampe, Teillieferungen und Ähnliches
totalLinesEnglisch {n} linesDeutsch {n} Positionen
totalUnitsEnglisch {n} pieces in totalDeutsch {n} Stück insgesamt
kindEnglisch Kind of projectDeutsch Art des Projekts
howEnglisch How should we answer?Deutsch Wie sollen wir antworten?
writeEnglisch Write to usDeutsch Schreiben
callEnglisch Call me backDeutsch Rückruf
windowEnglisch Best timeDeutsch Beste Zeit
windowNeededEnglisch Please choose a time window, so we call when it suits you.Deutsch Bitte wähle ein Zeitfenster, damit wir anrufen, wenn es dir passt.
questionEnglisch What is it about?Deutsch Worum geht es?
replyEnglisch Reply within {t}Deutsch Antwort innerhalb von {t}
consentEnglisch I agree that my details are used to prepare this quote.Deutsch Ich bin einverstanden, dass meine Angaben für dieses Angebot genutzt werden.
consentNeededEnglisch Please agree so we are allowed to use your details for the quote.Deutsch Bitte stimme zu, damit wir deine Angaben für das Angebot nutzen dürfen.
doneTitleEnglisch Thank you, your request is with usDeutsch Danke, deine Anfrage ist bei uns
doneTextEnglisch You get the quote by email, usually within one working day.Deutsch Das Angebot kommt per E-Mail, meist innerhalb eines Werktags.
callDoneEnglisch We call you {w}Deutsch Wir rufen dich an, {w}
againEnglisch Start a new requestDeutsch Neue Anfrage starten

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-describedby, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle radiogroup.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 680 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Angebotsanfrage?

Mit aiw add @ai-website/request-quote. Der Befehl legt die Datei components/ui/request-quote.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, textarea, select, slider, number-input, file-upload, checkbox, date-input, motion, reveal, section und form-flow. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Angebotsanfrage?

4 Varianten, Projektbriefing, Positionen, Felder nach Art und Schreiben oder Rückruf. Du wählst sie mit der Prop variant, zum Beispiel variant="line-items".

Kann ich die Texte von Angebotsanfrage übersetzen?

Ja. Angebotsanfrage bringt 45 eigene Texte auf Deutsch und Englisch mit, etwa „Was brauchst du?“, „Bitte wähle mindestens ein Thema, damit die richtige Person antwortet.“ und „Budget“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Angebotsanfrage einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Angebotsanfrage mit aiw_components und installieren sie mit aiw_add @ai-website/request-quote. Nenn im Prompt die Variante, etwa variant="brief", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Angebotsanfrage ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden RequestQuote-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert