Zum Inhalt springen
UI Kit
Accent

Rückrufbitte

Eine Nummer und ein Zeitpunkt, und das Unternehmen ruft an. Tage und Zeitfenster mit belegten angehalten, ein Live-Status mit Wartezeit und Anruf sofort, eine Zeile für den Hero oder das Thema, das entscheidet, wer anruft.

Eine von 4 Components in Anfrageformulare

4 Variants

Variants

Zeitfenster

Tage als Reiter mit gleitender Markierung und gezählten freien Fenstern, Fenster als Chips, belegte angehalten. Die Zeile unten sagt in Worten, wann der Anruf kommt, und ändert sich mit jeder Wahl.

Im Studio öffnen

Sofort oder später

Ein Live-Status mit atmendem Punkt und der Wartezeit in Minuten. Sofort ist ein Feld und ein großer Knopf, Zeit wählen öffnet Tage und Fenster mit weicher Höhe.

Im Studio öffnen

Leiste

Eine Zeile für Hero, Preisseite oder Fußbereich, Nummernfeld und Knopf nebeneinander, die Öffnungszeiten mit Live-Punkt. Bestätigt an Ort und Stelle, ohne die Seite zu verlassen.

Im Studio öffnen

Thema und Person

Das Thema entscheidet, wer anruft, und diese Person erscheint neben dem Formular mit Initialen, Rolle und Sprachen. Bevor ein Thema gewählt ist, steht dort das ganze Team in einer Reihe.

Im Studio öffnen

Details

Was Rückrufbitte mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/callback-request.tsx
Bringt mit
utilskitbuttoniconrevealsectionmotionform-flowinputtextareacheckbox
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 37 eigene Texte

Props

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

  • variantoptional
    CallbackRequestVariant

    time-slotsnow-or-laterinline-bartopic-agent

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • daysoptional
    CallbackDay[]
  • availableoptional
    boolean

    now-or-later: is someone free right now, and the wait in minutes

  • waitoptional
    number
  • hoursoptional
    string

    now-or-later and inline-bar: the opening hours line

  • agentsoptional
    CallbackAgent[]

    topic-agent

  • dialCodeoptional
    string

    Country code shown before the number, e.g. +49

  • askNameoptional
    boolean

    Ask for a name too

  • promiseoptional
    string

    inline-bar: quiet promise next to the field

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

Eingebaute Texte

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

Alle 37 Texte anzeigen
dayEnglisch DayDeutsch Tag
windowEnglisch Time windowDeutsch Zeitfenster
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.
fullEnglisch BookedDeutsch Belegt
freeEnglisch {n} freeDeutsch {n} frei
pickWindowEnglisch Choose a time window that suits youDeutsch Wähle ein Zeitfenster, das dir passt
pickTopicEnglisch Choose a topic and see who calls you.Deutsch Wähle ein Thema und sieh, wer dich anruft.
phoneEnglisch Your numberDeutsch Deine Nummer
phoneNeededEnglisch This number looks too short. Please enter it with the area code, so we reach you.Deutsch Diese Nummer wirkt zu kurz. Bitte gib sie mit Vorwahl ein, damit wir dich erreichen.
nameEnglisch NameDeutsch Name
topicEnglisch What is it about?Deutsch Worum geht es?
topicNeededEnglisch Please choose a topic, it decides who calls you.Deutsch Bitte wähle ein Thema, danach richtet sich, wer dich anruft.
noteEnglisch Anything we should know before the call?Deutsch Sollen wir vor dem Anruf etwas wissen?
consentEnglisch I agree to be called at this number about my request.Deutsch Ich bin einverstanden, unter dieser Nummer zu meiner Anfrage angerufen zu werden.
consentNeededEnglisch Please agree, so we are allowed to call you.Deutsch Bitte stimme zu, damit wir dich anrufen dürfen.
callEnglisch Call me backDeutsch Rückruf anfordern
callNowEnglisch Call me nowDeutsch Jetzt anrufen lassen
laterEnglisch Pick a timeDeutsch Zeit wählen
whenEnglisch When should we call?Deutsch Wann sollen wir anrufen?
nowEnglisch NowDeutsch Sofort
availableNowEnglisch Our team is free nowDeutsch Unser Team ist gerade frei
waitAboutEnglisch about {n} minutes waitDeutsch etwa {n} Minuten Wartezeit
busyEnglisch All lines are busy right nowDeutsch Gerade sind alle Leitungen belegt
busyTextEnglisch Pick a time and we call you then.Deutsch Wähle eine Zeit, dann rufen wir dich an.
youGetCallEnglisch We call you {when}Deutsch Wir rufen dich {when} an
betweenEnglisch between {w}Deutsch zwischen {w}
doneNowEnglisch We call you in about {n} minutesDeutsch Wir rufen dich in etwa {n} Minuten an
doneNowTextEnglisch Keep your phone close. The call comes from a number starting with {d}.Deutsch Halte dein Telefon bereit. Der Anruf kommt von einer Nummer, die mit {d} beginnt.
doneLaterEnglisch Your call is plannedDeutsch Dein Anruf ist geplant
doneLaterTextEnglisch We call you {d} {w}. You get a reminder by text one hour before.Deutsch Wir rufen dich {d} {w} an. Eine Stunde vorher bekommst du eine Erinnerung per SMS.
doneInlineEnglisch Thank you, we call you backDeutsch Danke, wir rufen zurück
doneInlineTextEnglisch Usually within 15 minutes during opening hours.Deutsch Meist innerhalb von 15 Minuten während der Öffnungszeiten.
changeEnglisch ChangeDeutsch Ändern
callsEnglisch Who calls youDeutsch Wer dich anruft
consentShortEnglisch Agreement to the callDeutsch Einverständnis zum Anruf
callsYouEnglisch {n} calls youDeutsch {n} ruft dich an
speaksEnglisch Speaks {l}Deutsch Spricht {l}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radiogroup und status.
  • 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.
  • 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).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • 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, 563 Zeilen
components/ui/callback-request.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, 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 { Checkbox } from '@/components/ui/checkbox';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ChipGroup, DoneMark, ErrorSummary, FlowHead, isPhone, Unfold, useFocusFirstError, summaryOf, useFormState } from '@/components/ui/form-flow';

/*
 * Asking for a call back. The visitor leaves a number and a moment, the
 * business calls. Every variant says plainly when the call comes.
 *
 *   time-slots    days as tabs, time windows as chips, full ones paused
 *   now-or-later  a live status with the wait, call now or pick a time
 *   inline-bar    one row for a hero or a footer, number and button
 *   topic-agent   the topic decides who calls, that person shows up with name and languages
 *
 * Sends { phone, name, day, window, topic, now } to `onSubmit`.
 */

export type CallbackRequestVariant = 'time-slots' | 'now-or-later' | 'inline-bar' | 'topic-agent';

export interface CallbackDay {
  value: string;
  /** e.g. Today, Tomorrow, Thu 23 */
  label: string;
  /** Time windows with their state */
  windows: { value: string; label: string; full?: boolean }[];
}

export interface CallbackAgent {
  topic: string;
  /** Topic label as shown */
  label: string;
  icon?: IconName;
  name: string;
  role?: string;
  initials?: string;
  languages?: string;
}

export interface CallbackRequestProps {
  variant?: CallbackRequestVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  days?: CallbackDay[];
  /** now-or-later: is someone free right now, and the wait in minutes */
  available?: boolean;
  wait?: number;
  /** now-or-later and inline-bar: the opening hours line */
  hours?: string;
  /** topic-agent */
  agents?: CallbackAgent[];
  /** Country code shown before the number, e.g. +49 */
  dialCode?: string;
  /** Ask for a name too */
  askName?: boolean;
  /** inline-bar: quiet promise next to the field */
  promise?: string;
  submitLabel?: string;
  done?: { title: string; text?: string };
  onSubmit?: (data: Record<string, unknown>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    day: 'Day',
    window: 'Time window',
    windowNeeded: 'Please choose a time window, so we call when it suits you.',
    full: 'Booked',
    free: '{n} free',
    pickWindow: 'Choose a time window that suits you',
    pickTopic: 'Choose a topic and see who calls you.',
    phone: 'Your number',
    phoneNeeded: 'This number looks too short. Please enter it with the area code, so we reach you.',
    name: 'Name',
    topic: 'What is it about?',
    topicNeeded: 'Please choose a topic, it decides who calls you.',
    note: 'Anything we should know before the call?',
    consent: 'I agree to be called at this number about my request.',
    consentNeeded: 'Please agree, so we are allowed to call you.',
    call: 'Call me back',
    callNow: 'Call me now',
    later: 'Pick a time',
    when: 'When should we call?',
    now: 'Now',
    availableNow: 'Our team is free now',
    waitAbout: 'about {n} minutes wait',
    busy: 'All lines are busy right now',
    busyText: 'Pick a time and we call you then.',
    youGetCall: 'We call you {when}',
    between: 'between {w}',
    doneNow: 'We call you in about {n} minutes',
    doneNowText: 'Keep your phone close. The call comes from a number starting with {d}.',
    doneLater: 'Your call is planned',
    doneLaterText: 'We call you {d} {w}. You get a reminder by text one hour before.',
    doneInline: 'Thank you, we call you back',
    doneInlineText: 'Usually within 15 minutes during opening hours.',
    change: 'Change',
    calls: 'Who calls you',
    consentShort: 'Agreement to the call',
    callsYou: '{n} calls you',
    speaks: 'Speaks {l}',
  },
  de: {
    day: 'Tag',
    window: 'Zeitfenster',
    windowNeeded: 'Bitte wähle ein Zeitfenster, damit wir anrufen, wenn es dir passt.',
    full: 'Belegt',
    free: '{n} frei',
    pickWindow: 'Wähle ein Zeitfenster, das dir passt',
    pickTopic: 'Wähle ein Thema und sieh, wer dich anruft.',
    phone: 'Deine Nummer',
    phoneNeeded: 'Diese Nummer wirkt zu kurz. Bitte gib sie mit Vorwahl ein, damit wir dich erreichen.',
    name: 'Name',
    topic: 'Worum geht es?',
    topicNeeded: 'Bitte wähle ein Thema, danach richtet sich, wer dich anruft.',
    note: 'Sollen wir vor dem Anruf etwas wissen?',
    consent: 'Ich bin einverstanden, unter dieser Nummer zu meiner Anfrage angerufen zu werden.',
    consentNeeded: 'Bitte stimme zu, damit wir dich anrufen dürfen.',
    call: 'Rückruf anfordern',
    callNow: 'Jetzt anrufen lassen',
    later: 'Zeit wählen',
    when: 'Wann sollen wir anrufen?',
    now: 'Sofort',
    availableNow: 'Unser Team ist gerade frei',
    waitAbout: 'etwa {n} Minuten Wartezeit',
    busy: 'Gerade sind alle Leitungen belegt',
    busyText: 'Wähle eine Zeit, dann rufen wir dich an.',
    youGetCall: 'Wir rufen dich {when} an',
    between: 'zwischen {w}',
    doneNow: 'Wir rufen dich in etwa {n} Minuten an',
    doneNowText: 'Halte dein Telefon bereit. Der Anruf kommt von einer Nummer, die mit {d} beginnt.',
    doneLater: 'Dein Anruf ist geplant',
    doneLaterText: 'Wir rufen dich {d} {w} an. Eine Stunde vorher bekommst du eine Erinnerung per SMS.',
    doneInline: 'Danke, wir rufen zurück',
    doneInlineText: 'Meist innerhalb von 15 Minuten während der Öffnungszeiten.',
    change: 'Ändern',
    calls: 'Wer dich anruft',
    consentShort: 'Einverständnis zum Anruf',
    callsYou: '{n} ruft dich an',
    speaks: 'Spricht {l}',
  },
};

type Texts = (typeof TEXTS)['en'];

/** A short confirmation with the moment of the call, and a way to change it */
function Planned({ title, text, onChange, t, className }: { title: string; text: string; onChange: () => void; t: Texts; className?: string }) {
  const ref = useRef<HTMLParagraphElement>(null);
  useEffect(() => ref.current?.focus({ preventScroll: true }), []);
  return (
    <div role="status" className={cn('kit-rise flex flex-col items-center py-4 text-center', className)}>
      <DoneMark size={56} />
      <p ref={ref} tabIndex={-1} className="mt-5 text-balance text-[20px] font-semibold tracking-[-0.02em] text-foreground outline-none">
        {title}
      </p>
      <p className="mt-2 max-w-[28rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{text}</p>
      <Button variant="ghost" size="sm" iconLeft="edit" onClick={onChange} className="mt-4">
        {t.change}
      </Button>
    </div>
  );
}

function PhoneField({ id, t, value, set, error, dialCode, size = 'md', label = true }: { id: string; t: Texts; value: string; set: (v: string) => void; error?: string; dialCode?: string; size?: 'md' | 'lg'; label?: boolean }) {
  return (
    <Input
      id={id}
      type="tel"
      inputMode="tel"
      autoComplete="tel-national"
      label={label ? t.phone : undefined}
      aria-label={label ? undefined : t.phone}
      required
      size={size}
      startAddon={dialCode}
      icon={dialCode ? undefined : 'phone'}
      value={value}
      onChange={(e) => set(e.target.value)}
      error={error}
    />
  );
}

/* ------------------------------------------------------------------ */
/* time-slots                                                          */
/* ------------------------------------------------------------------ */

function TimeSlots({ p, t }: { p: CallbackRequestProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const days = p.days ?? [];
  const [day, setDay] = useState(days[0]?.value ?? '');
  const [win, setWin] = useState<string[]>([]);
  const [phone, setPhone] = useState('');
  const [name, setName] = useState('');
  const [consent, setConsent] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const current = days.find((d) => d.value === day) ?? days[0];
  const index = Math.max(0, days.findIndex((d) => d.value === day));
  const chosenWindow = current?.windows.find((w) => w.value === win[0]);
  const summary = summaryOf(f.errors, [
    ['window', `${id}-w`, t.window],
    ['phone', `${id}-phone`, t.phone],
    ['consent', `${id}-consent`, t.consentShort],
  ]);
  if (f.done)
    return (
      <div className="kit-card mx-auto max-w-[36rem] p-6 sm:p-10">
        <Planned title={t.doneLater} text={t.doneLaterText.replace('{d}', current?.label ?? '').replace('{w}', t.between.replace('{w}', chosenWindow?.label ?? ''))} onChange={() => f.setDone(false)} t={t} />
      </div>
    );
  return (
    <Reveal effect="rise" className="mx-auto max-w-[40rem]">
      <form
        ref={formRef}
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!win.length) found.window = t.windowNeeded;
          if (!isPhone(phone)) found.phone = t.phoneNeeded;
          if (!consent) found.consent = t.consentNeeded;
          if (f.fail(found)) return;
          p.onSubmit?.({ day, window: win[0], phone, name });
          f.setDone(true);
        }}
      >
        {/* Days as tabs with a sliding marker, arrow keys move between them */}
        <div role="radiogroup" aria-label={t.day} className="relative grid border-b border-border bg-surface" style={{ gridTemplateColumns: `repeat(${days.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: `${Math.round((100 / Math.max(1, days.length)) * 1000) / 1000}%`, translate: `${index * 100}% 0` }} />
          {days.map((d, i) => {
            const free = d.windows.filter((w) => !w.full).length;
            return (
              <label key={d.value} htmlFor={`${id}-d${i}`} className="flex min-h-16 cursor-pointer flex-col items-center justify-center gap-0.5 px-1 py-3 text-center has-[:focus-visible]:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
                <input id={`${id}-d${i}`} type="radio" name={`${id}-day`} checked={day === d.value} onChange={() => (setDay(d.value), setWin([]))} className="sr-only" />
                <span className={cn('text-[14px] font-medium transition-colors', day === d.value ? 'text-foreground' : 'text-muted-foreground')}>{d.label}</span>
                <span className="text-[11.5px] tabular-nums text-muted-foreground">{t.free.replace('{n}', String(free))}</span>
              </label>
            );
          })}
        </div>
        <div className="grid gap-6 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <div key={day} className="kit-rise">
            <ChipGroup id={`${id}-w`} legend={t.window} required layout={3} options={(current?.windows ?? []).map((w) => ({ value: w.value, label: w.label, meta: w.full ? t.full : undefined, disabled: w.full }))} value={win} onChange={(v) => (setWin(v), f.clear('window'))} error={f.errors.window} />
          </div>
          <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
            <PhoneField id={`${id}-phone`} t={t} value={phone} set={(v) => (setPhone(v), f.clear('phone'))} error={f.errors.phone} dialCode={p.dialCode} />
            {p.askName !== false && <Input label={t.name} optional autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} />}
          </div>
          <Checkbox id={`${id}-consent`} label={t.consent} checked={consent} onChange={(e) => (setConsent(e.target.checked), f.clear('consent'))} error={f.errors.consent} />
        </div>
        {/* The promise in words, it updates with the choice */}
        <div className="flex flex-col gap-4 border-t border-border bg-surface px-6 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-8">
          <p aria-live="polite" className="flex items-center gap-2.5 text-[14px] text-muted-foreground">
            <Icon name="phone-call" size={17} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
            {chosenWindow ? <span key={chosenWindow.value} className="kit-rise font-medium text-foreground">{t.youGetCall.replace('{when}', `${current?.label ?? ''} ${t.between.replace('{w}', chosenWindow.label)}`)}</span> : t.pickWindow}
          </p>
          <Button type="submit" icon="phone-call" className="max-sm:w-full">
            {p.submitLabel ?? t.call}
          </Button>
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* now-or-later                                                        */
/* ------------------------------------------------------------------ */

function NowOrLater({ p, t }: { p: CallbackRequestProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const available = p.available !== false;
  const [mode, setMode] = useState<'now' | 'later'>(available ? 'now' : 'later');
  const days = p.days ?? [];
  const [day, setDay] = useState<string[]>(days[0] ? [days[0].value] : []);
  const [win, setWin] = useState<string[]>([]);
  const [phone, setPhone] = useState('');
  const formRef = useRef<HTMLFormElement>(null);
  useFocusFirstError(formRef, f.attempt);
  const current = days.find((d) => d.value === day[0]);
  const wait = p.wait ?? 3;
  if (f.done)
    return (
      <div className="kit-card mx-auto max-w-[36rem] p-6 sm:p-10">
        {mode === 'now' ? (
          <Planned title={t.doneNow.replace('{n}', String(wait))} text={t.doneNowText.replace('{d}', p.dialCode ?? '')} onChange={() => f.setDone(false)} t={t} />
        ) : (
          <Planned title={t.doneLater} text={t.doneLaterText.replace('{d}', current?.label ?? '').replace('{w}', t.between.replace('{w}', current?.windows.find((w) => w.value === win[0])?.label ?? ''))} onChange={() => f.setDone(false)} t={t} />
        )}
      </div>
    );
  return (
    <Reveal effect="scale" className="mx-auto max-w-[38rem]">
      <form
        ref={formRef}
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!isPhone(phone)) found.phone = t.phoneNeeded;
          if (mode === 'later' && !win.length) found.window = t.windowNeeded;
          if (f.fail(found)) return;
          p.onSubmit?.({ now: mode === 'now', phone, day: day[0], window: win[0] });
          f.setDone(true);
        }}
      >
        {/* Live status: a dot that breathes while someone is free */}
        <div className="flex items-center gap-3.5 border-b border-border px-6 py-5 sm:px-8">
          <span className="relative grid size-3 place-items-center">
            {available && <span className="absolute inset-0 rounded-full bg-success opacity-60 motion-safe:animate-ping" />}
            <span className={cn('relative size-3 rounded-full', available ? 'bg-success' : 'bg-warning')} />
          </span>
          <span className="min-w-0 flex-1">
            <span className="block text-[15px] font-semibold text-foreground">{available ? t.availableNow : t.busy}</span>
            <span className="block text-[13px] text-muted-foreground">{available ? t.waitAbout.replace('{n}', String(wait)) : t.busyText}</span>
          </span>
          {p.hours && <span className="hidden shrink-0 text-[12.5px] text-muted-foreground sm:block">{p.hours}</span>}
        </div>
        <div className="grid gap-6 p-6 sm:p-8">
          {/* The switch and the days it opens are one block, so the closed days cost no gap */}
          <div>
          {available && (
            <div role="radiogroup" aria-label={t.when} 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 === 'later' && 'translate-x-full rtl:-translate-x-full')} />
              {(['now', 'later'] as const).map((m) => (
                <label key={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 type="radio" name={`${id}-mode`} checked={mode === m} onChange={() => (setMode(m), f.clear('window'))} className="sr-only" />
                  <Icon name={m === 'now' ? 'bolt' : 'calendar'} size={16} />
                  {m === 'now' ? t.now : t.later}
                </label>
              ))}
            </div>
          )}
          <Unfold open={mode === 'later'}>
            <div className={cn('grid gap-5 pb-1', available && 'pt-6')}>
              <ChipGroup legend={t.day} options={days.map((d) => ({ value: d.value, label: d.label }))} value={day} onChange={(v) => (setDay(v), setWin([]))} />
              <ChipGroup id={`${id}-w`} legend={t.window} required layout={3} options={(current?.windows ?? []).map((w) => ({ value: w.value, label: w.label, meta: w.full ? t.full : undefined, disabled: w.full }))} value={win} onChange={(v) => (setWin(v), f.clear('window'))} error={f.errors.window} />
            </div>
          </Unfold>
          </div>
          <PhoneField id={`${id}-phone`} t={t} value={phone} set={(v) => (setPhone(v), f.clear('phone'))} error={f.errors.phone} dialCode={p.dialCode} size="lg" />
          <Button type="submit" size="lg" icon={mode === 'now' ? 'phone-call' : 'calendar-check'} magnetic className="w-full">
            {mode === 'now' ? t.callNow : (p.submitLabel ?? t.call)}
          </Button>
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* inline-bar                                                          */
/* ------------------------------------------------------------------ */

function InlineBar({ p, t }: { p: CallbackRequestProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const [phone, setPhone] = useState('');
  const inputRef = useRef<HTMLDivElement>(null);
  return (
    <Reveal effect="rise">
      <div className="relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
        <div aria-hidden className="pointer-events-none absolute inset-0 kit-glow opacity-60" />
        <div className="relative grid gap-6 p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:items-center lg:gap-12 lg:p-10">
          <div>
            <FlowHead eyebrow={p.eyebrow} title={p.title} description={p.description} size="card" />
            {p.hours && (
              <p className="mt-4 flex items-center gap-2 text-[13px] text-muted-foreground">
                <span className="relative grid size-2.5 place-items-center">
                  <span className="absolute inset-0 rounded-full bg-success opacity-60 motion-safe:animate-ping" />
                  <span className="relative size-2.5 rounded-full bg-success" />
                </span>
                {p.hours}
              </p>
            )}
          </div>
          <div ref={inputRef}>
            {f.done ? (
              <div role="status" className="kit-rise flex items-center gap-4 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4">
                <DoneMark size={44} />
                <span>
                  <span className="block text-[15px] font-semibold text-foreground">{t.doneInline}</span>
                  <span className="block text-[13.5px] text-muted-foreground">{t.doneInlineText}</span>
                </span>
              </div>
            ) : (
              <form
                noValidate
                onSubmit={(e) => {
                  e.preventDefault();
                  if (f.fail(isPhone(phone) ? {} : { phone: t.phoneNeeded })) {
                    requestAnimationFrame(() => document.getElementById(`${id}-phone`)?.focus());
                    return;
                  }
                  p.onSubmit?.({ phone });
                  f.setDone(true);
                }}
              >
                <div className="flex flex-col gap-2 sm:flex-row sm:items-start">
                  <div className="min-w-0 sm:flex-1">
                    <PhoneField id={`${id}-phone`} t={t} value={phone} set={(v) => (setPhone(v), f.clear('phone'))} error={f.errors.phone} dialCode={p.dialCode} size="lg" label={false} />
                  </div>
                  <Button type="submit" size="lg" icon="phone-call" className="max-sm:w-full">
                    {p.submitLabel ?? t.call}
                  </Button>
                </div>
                {p.promise && !f.errors.phone && <p className="mt-2.5 text-[13px] text-muted-foreground">{p.promise}</p>}
              </form>
            )}
          </div>
        </div>
      </div>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* topic-agent                                                         */
/* ------------------------------------------------------------------ */

function TopicAgent({ p, t }: { p: CallbackRequestProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const agents = p.agents ?? [];
  const days = p.days ?? [];
  const [topic, setTopic] = useState<string[]>([]);
  const [day, setDay] = useState<string[]>(days[0] ? [days[0].value] : []);
  const [win, setWin] = useState<string[]>([]);
  const [phone, setPhone] = useState('');
  const [name, setName] = useState('');
  const [note, setNote] = useState('');
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const agent = agents.find((a) => a.topic === topic[0]);
  const current = days.find((d) => d.value === day[0]);
  const summary = summaryOf(f.errors, [
    ['topic', `${id}-topic`, t.topic],
    ['window', `${id}-w`, t.window],
    ['phone', `${id}-phone`, t.phone],
  ]);
  if (f.done)
    return (
      <div className="kit-card mx-auto max-w-[36rem] p-6 sm:p-10">
        <Planned title={t.doneLater} text={t.doneLaterText.replace('{d}', current?.label ?? '').replace('{w}', t.between.replace('{w}', current?.windows.find((w) => w.value === win[0])?.label ?? ''))} onChange={() => f.setDone(false)} t={t} />
      </div>
    );
  return (
    <form
      ref={formRef}
      noValidate
      className="mx-auto grid max-w-[60rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start lg:gap-8"
      onSubmit={(e) => {
        e.preventDefault();
        const found: Record<string, string> = {};
        if (!topic.length) found.topic = t.topicNeeded;
        if (!win.length) found.window = t.windowNeeded;
        if (!isPhone(phone)) found.phone = t.phoneNeeded;
        if (f.fail(found)) return;
        p.onSubmit?.({ topic: topic[0], day: day[0], window: win[0], phone, name, note });
        f.setDone(true);
      }}
    >
      <Reveal effect="rise" className="kit-card grid min-w-0 gap-6 p-6 sm:p-8">
        <ErrorSummary ref={summaryRef} errors={summary} />
        <ChipGroup id={`${id}-topic`} legend={t.topic} required layout={2} options={agents.map((a) => ({ value: a.topic, label: a.label, icon: a.icon }))} value={topic} onChange={(v) => (setTopic(v), f.clear('topic'))} error={f.errors.topic} />
        {/* Phones and tablets: who calls appears right under the topic */}
        {agent && (
          <p key={agent.topic} aria-hidden className="kit-rise -mt-2 flex items-center gap-3 rounded-xl bg-surface p-3 text-[13.5px] text-muted-foreground lg:hidden">
            <span className="grid size-9 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[12px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{agent.initials ?? agent.name.split(' ').map((s) => s[0]).join('').slice(0, 2)}</span>
            <span>
              <span className="font-medium text-foreground">{t.callsYou.replace('{n}', agent.name)}</span>
              {agent.languages ? ` · ${t.speaks.replace('{l}', agent.languages)}` : ''}
            </span>
          </p>
        )}
        <ChipGroup legend={t.day} layout={days.length === 4 ? 4 : 'wrap'} options={days.map((d) => ({ value: d.value, label: d.label }))} value={day} onChange={(v) => (setDay(v), setWin([]))} />
        <div key={day[0]} className="kit-rise">
          <ChipGroup id={`${id}-w`} legend={t.window} required layout={3} options={(current?.windows ?? []).map((w) => ({ value: w.value, label: w.label, meta: w.full ? t.full : undefined, disabled: w.full }))} value={win} onChange={(v) => (setWin(v), f.clear('window'))} error={f.errors.window} />
        </div>
        <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
          <PhoneField id={`${id}-phone`} t={t} value={phone} set={(v) => (setPhone(v), f.clear('phone'))} error={f.errors.phone} dialCode={p.dialCode} />
          <Input label={t.name} optional autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} />
        </div>
        <Textarea label={t.note} optional value={note} onChange={(e) => setNote(e.target.value)} minRows={2} maxLength={400} />
        <Button type="submit" icon="phone-call" className="sm:justify-self-start max-sm:w-full">
          {p.submitLabel ?? t.call}
        </Button>
      </Reveal>
      {/* The person who calls appears with the topic, the card turns over softly */}
      <Reveal effect="rise" delay={120} className="lg:sticky lg:top-24">
        <aside aria-live="polite" className="rounded-2xl border border-border bg-surface p-6">
          <p className="text-[13px] font-medium text-muted-foreground">{t.calls}</p>
          {agent ? (
            <div key={agent.topic} className="kit-rise mt-4">
              <span className="grid size-16 place-items-center rounded-2xl bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[20px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
                {agent.initials ?? agent.name.split(' ').map((s) => s[0]).join('').slice(0, 2)}
              </span>
              <p className="mt-4 text-[17px] font-semibold text-foreground">{agent.name}</p>
              {agent.role && <p className="text-[13.5px] text-muted-foreground">{agent.role}</p>}
              {agent.languages && (
                <p className="mt-3 flex items-center gap-2 text-[13px] text-muted-foreground">
                  <Icon name="languages" size={15} />
                  {t.speaks.replace('{l}', agent.languages)}
                </p>
              )}
            </div>
          ) : (
            <Stagger step={90} as="div" className="mt-4 flex -space-x-2">
              {agents.map((a) => (
                <span key={a.topic} className="grid size-11 place-items-center rounded-full border-2 border-surface bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[13px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
                  {a.initials ?? a.name.split(' ').map((s) => s[0]).join('').slice(0, 2)}
                </span>
              ))}
            </Stagger>
          )}
          {!agent && <p className="mt-3 text-[13.5px] leading-snug text-muted-foreground">{t.pickTopic}</p>}
        </aside>
      </Reveal>
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

/**
 * A call back request.
 *   <CallbackRequest variant="time-slots" days={[{ value, label, windows: [{ value, label, full }] }]} dialCode="+49" />
 *   <CallbackRequest variant="inline-bar" title="Prefer to talk?" hours="Mon to Fri, 8 am to 6 pm" />
 */
export function CallbackRequest(props: CallbackRequestProps) {
  const { variant = 'time-slots', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section className={className}>
      {variant !== 'inline-bar' && <FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />}
      {variant === 'time-slots' && <TimeSlots p={props} t={t} />}
      {variant === 'now-or-later' && <NowOrLater p={props} t={t} />}
      {variant === 'inline-bar' && <InlineBar p={props} t={t} />}
      {variant === 'topic-agent' && <TopicAgent p={props} t={t} />}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Rückrufbitte?

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

Welche Varianten hat Rückrufbitte?

4 Varianten, Zeitfenster, Sofort oder später, Leiste und Thema und Person. Du wählst sie mit der Prop variant, zum Beispiel variant="now-or-later".

Kann ich die Texte von Rückrufbitte übersetzen?

Ja. Rückrufbitte bringt 37 eigene Texte auf Deutsch und Englisch mit, etwa „Tag“, „Zeitfenster“ und „Bitte wähle ein Zeitfenster, damit wir anrufen, wenn es dir passt.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Rückrufbitte einbauen?

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

Kann ich Rückrufbitte ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert