Zum Inhalt springen
UI Kit
Accent

Chat-Widget

Ein Support-Chat für Websites mit einem Starter samt Hilfe-Start, einem Gespräch mit KI-Hinweis und Schnellantworten, der Übergabe an einen Menschen mit Warteschlange, einem Kontaktformular im Chat und einer KI-Antwort direkt auf der Hilfeseite.

Eine von 2 Components in KI-Assistenten

5 Variants

Variants

Starter mit Startseite

Ein runder Knopf in der Ecke mit kurzem Anstoß. Geöffnet zeigt er Begrüßung, Fragefeld, Schnellaktionen und beliebte Artikel, Telefone bekommen ein ganzes Panel.

Im Studio öffnen

Gespräch

Der offene Chat mit klarem Hinweis, dass eine KI antwortet, Quellen unter Antworten, Schnellantworten und Schreibanzeige. Enter sendet.

Im Studio öffnen

Übergabe an einen Menschen

Der Bot übergibt, die Warteschlange zählt beim Hereinscrollen herunter, dann kommt der Mensch mit dem bisherigen Verlauf dazu. Statt zu warten geht auch E-Mail.

Im Studio öffnen

Kontaktformular im Chat

Der Bot fragt im Gespräch nach Name und E-Mail, mit Einwilligung und Link zur Datenschutzerklärung. Fehler sagen, was zu tun ist.

Im Studio öffnen

Antwort auf der Hilfeseite

Eine Suche, die antwortet. Die KI-Antwort aus den Hilfeartikeln steht direkt unter dem Feld, mit Link zum Artikel, kurzer Rückfrage und dem Weg zum Team.

Im Studio öffnen

Details

Was Chat-Widget mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/chat-widget.tsx
Bringt mit
utilskiticonreveal
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 40 eigene Texte
In Sammlungen
KI-Produkt

Props

Die Schnittstelle ChatWidgetProps aus components/ui/chat-widget.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ChatWidgetVariant

    launcherconversationhandofflead-capturehelp-embed

  • namePflicht
    string

    Name of the assistant, e.g. "Lumen Help"

  • taglineoptional
    string

    Short line under the name, e.g. "Answers right away"

  • greetingoptional
    string
  • actionsoptional
    WidgetAction[]
  • articlesoptional
    WidgetArticle[]
  • messagesoptional
    WidgetMessage[]
  • suggestionsoptional
    string[]

    Quick replies under the last message

  • agentoptional
    { name: string; role?: string; initials?: string; /** First message after joining */ greeting?: string }

    handoff: the person who takes over

  • queueoptional
    { position: number; wait: string }

    handoff: position in the queue and the expected wait

  • questionoptional
    string

    help-embed: the question already asked and its answer

  • answeroptional
    string
  • defaultOpenoptional
    boolean

    Start open

  • positionoptional
    'fixed' | 'contained'

    contained keeps the widget inside its parent (for previews), fixed pins it to the window

  • backdropoptional
    React.ReactNode

    contained: the page behind the widget

  • respondoptional
    (text: string) => string | Promise<string>

    Produces the bot reply, without it the widget answers with a short built-in text

  • onLeadoptional
    (lead: { name: string; email: string }) => void

Eingebaute Texte

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

Alle 40 Texte anzeigen
openEnglisch Open chatDeutsch Chat öffnen
closeEnglisch Close chatDeutsch Chat schließen
messageEnglisch Write a messageDeutsch Nachricht schreiben
sendEnglisch SendDeutsch Senden
onlineEnglisch Answers right awayDeutsch Antwortet sofort
aiNoteEnglisch Answers come from an AI and can be wrong. A person takes over on request.Deutsch Antworten kommen von einer KI und können falsch sein. Auf Wunsch übernimmt ein Mensch.
typingEnglisch {name} is typingDeutsch {name} schreibt
askEnglisch Ask a questionDeutsch Frage stellen
popularEnglisch Popular articlesDeutsch Beliebte Artikel
teaserEnglisch Hi, can I help you find something?Deutsch Hallo, kann ich dir beim Finden helfen?
dismissEnglisch DismissDeutsch Ausblenden
fallbackEnglisch Thanks for your message. I found two articles that may help, and a person from the team reads along if you need more.Deutsch Danke für deine Nachricht. Ich habe zwei Artikel gefunden, die helfen könnten, und jemand aus dem Team liest mit, falls du mehr brauchst.
queueEnglisch You are number {n} in lineDeutsch Du bist Nummer {n} in der Warteschlange
waitEnglisch Usually {wait}Deutsch Meist {wait}
joinedEnglisch {name} joined the chatDeutsch {name} ist dem Chat beigetreten
nowEnglisch nowDeutsch jetzt
leftEnglisch Leave the queueDeutsch Warteschlange verlassen
emailInsteadEnglisch Get the answer by email insteadDeutsch Antwort lieber per E-Mail
emailAskEnglisch Write your email address below and the team replies there.Deutsch Schreib unten deine E-Mail-Adresse, dann antwortet das Team dort.
leftNoteEnglisch You left the queue. The assistant keeps answering here.Deutsch Du hast die Warteschlange verlassen. Der Assistent antwortet hier weiter.
leadTitleEnglisch Where can we reach you?Deutsch Wie erreichen wir dich?
leadNoteEnglisch So the team can answer if you close this window.Deutsch Damit das Team antworten kann, auch wenn du das Fenster schließt.
nameEnglisch NameDeutsch Name
emailEnglisch EmailDeutsch E-Mail
consentEnglisch I agree that my details are used to answer this request.Deutsch Ich bin einverstanden, dass meine Angaben für diese Anfrage genutzt werden.
privacyEnglisch Privacy policyDeutsch Datenschutzerklärung
submitEnglisch Send detailsDeutsch Angaben senden
leadDoneEnglisch Thanks, {name}. We will reply to {email}.Deutsch Danke, {name}. Wir antworten an {email}.
invalidEmailEnglisch Enter an email like name@example.comDeutsch Gib eine E-Mail wie name@beispiel.de ein
requiredEnglisch Fill in name and emailDeutsch Name und E-Mail ausfüllen
searchHelpEnglisch Search help or ask a questionDeutsch Hilfe durchsuchen oder Frage stellen
stillHelpEnglisch Still need help?Deutsch Noch Fragen offen?
contactEnglisch Write to the teamDeutsch Dem Team schreiben
helpfulEnglisch Did this answer your question?Deutsch Hat das deine Frage beantwortet?
yesEnglisch YesDeutsch Ja
noEnglisch NoDeutsch Nein
thanksEnglisch Thanks for the feedback.Deutsch Danke für die Rückmeldung.
answerFromEnglisch Answer from {name}Deutsch Antwort von {name}
readMoreEnglisch Read the full articleDeutsch Ganzen Artikel lesen
poweredNoteEnglisch Generated from our help articles. Check details in the article.Deutsch Aus unseren Hilfeartikeln erstellt. Details stehen im Artikel.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Chat-Widget reagiert selbst auf die Tasten Escape, Enter und Leertaste.
  • Setzt aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, dialog, search 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.
  • 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).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 848 Zeilen
components/ui/chat-widget.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';

export type ChatWidgetVariant = 'launcher' | 'conversation' | 'handoff' | 'lead-capture' | 'help-embed';

export interface WidgetMessage {
  id: string;
  from: 'bot' | 'user' | 'agent';
  text: string;
  time?: string;
  sources?: { title: string; href: string }[];
}

export interface WidgetAction {
  id: string;
  label: string;
  icon?: IconName;
  /** What the visitor sends when choosing it */
  prompt?: string;
}

export interface WidgetArticle {
  id: string;
  title: string;
  /** e.g. "2 min read" */
  meta?: string;
  href?: string;
}

export interface ChatWidgetProps {
  variant?: ChatWidgetVariant;
  /** Name of the assistant, e.g. "Lumen Help" */
  name: string;
  /** Short line under the name, e.g. "Answers right away" */
  tagline?: string;
  greeting?: string;
  actions?: WidgetAction[];
  articles?: WidgetArticle[];
  messages?: WidgetMessage[];
  /** Quick replies under the last message */
  suggestions?: string[];
  /** handoff: the person who takes over */
  agent?: { name: string; role?: string; initials?: string; /** First message after joining */ greeting?: string };
  /** handoff: position in the queue and the expected wait */
  queue?: { position: number; wait: string };
  /** help-embed: the question already asked and its answer */
  question?: string;
  answer?: string;
  /** Start open */
  defaultOpen?: boolean;
  /** contained keeps the widget inside its parent (for previews), fixed pins it to the window */
  position?: 'fixed' | 'contained';
  /** contained: the page behind the widget */
  backdrop?: React.ReactNode;
  /** Produces the bot reply, without it the widget answers with a short built-in text */
  respond?: (text: string) => string | Promise<string>;
  onLead?: (lead: { name: string; email: string }) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    open: 'Open chat',
    close: 'Close chat',
    
    message: 'Write a message',
    send: 'Send',
    online: 'Answers right away',
    
    aiNote: 'Answers come from an AI and can be wrong. A person takes over on request.',
    
    typing: '{name} is typing',
    
    ask: 'Ask a question',
    popular: 'Popular articles',
    teaser: 'Hi, can I help you find something?',
    dismiss: 'Dismiss',
    fallback: 'Thanks for your message. I found two articles that may help, and a person from the team reads along if you need more.',
    queue: 'You are number {n} in line',
    wait: 'Usually {wait}',
    joined: '{name} joined the chat',
    now: 'now',
    left: 'Leave the queue',
    emailInstead: 'Get the answer by email instead',
    emailAsk: 'Write your email address below and the team replies there.',
    leftNote: 'You left the queue. The assistant keeps answering here.',
    leadTitle: 'Where can we reach you?',
    leadNote: 'So the team can answer if you close this window.',
    name: 'Name',
    email: 'Email',
    consent: 'I agree that my details are used to answer this request.',
    privacy: 'Privacy policy',
    submit: 'Send details',
    leadDone: 'Thanks, {name}. We will reply to {email}.',
    invalidEmail: 'Enter an email like name@example.com',
    required: 'Fill in name and email',
    searchHelp: 'Search help or ask a question',
    stillHelp: 'Still need help?',
    contact: 'Write to the team',
    helpful: 'Did this answer your question?',
    yes: 'Yes',
    no: 'No',
    thanks: 'Thanks for the feedback.',
    answerFrom: 'Answer from {name}',
    readMore: 'Read the full article',
    poweredNote: 'Generated from our help articles. Check details in the article.',
  },
  de: {
    open: 'Chat öffnen',
    close: 'Chat schließen',
    
    message: 'Nachricht schreiben',
    send: 'Senden',
    online: 'Antwortet sofort',
    
    aiNote: 'Antworten kommen von einer KI und können falsch sein. Auf Wunsch übernimmt ein Mensch.',
    
    typing: '{name} schreibt',
    
    ask: 'Frage stellen',
    popular: 'Beliebte Artikel',
    teaser: 'Hallo, kann ich dir beim Finden helfen?',
    dismiss: 'Ausblenden',
    fallback: 'Danke für deine Nachricht. Ich habe zwei Artikel gefunden, die helfen könnten, und jemand aus dem Team liest mit, falls du mehr brauchst.',
    queue: 'Du bist Nummer {n} in der Warteschlange',
    wait: 'Meist {wait}',
    joined: '{name} ist dem Chat beigetreten',
    now: 'jetzt',
    left: 'Warteschlange verlassen',
    emailInstead: 'Antwort lieber per E-Mail',
    emailAsk: 'Schreib unten deine E-Mail-Adresse, dann antwortet das Team dort.',
    leftNote: 'Du hast die Warteschlange verlassen. Der Assistent antwortet hier weiter.',
    leadTitle: 'Wie erreichen wir dich?',
    leadNote: 'Damit das Team antworten kann, auch wenn du das Fenster schließt.',
    name: 'Name',
    email: 'E-Mail',
    consent: 'Ich bin einverstanden, dass meine Angaben für diese Anfrage genutzt werden.',
    privacy: 'Datenschutzerklärung',
    submit: 'Angaben senden',
    leadDone: 'Danke, {name}. Wir antworten an {email}.',
    invalidEmail: 'Gib eine E-Mail wie name@beispiel.de ein',
    required: 'Name und E-Mail ausfüllen',
    searchHelp: 'Hilfe durchsuchen oder Frage stellen',
    stillHelp: 'Noch Fragen offen?',
    contact: 'Dem Team schreiben',
    helpful: 'Hat das deine Frage beantwortet?',
    yes: 'Ja',
    no: 'Nein',
    thanks: 'Danke für die Rückmeldung.',
    answerFrom: 'Antwort von {name}',
    readMore: 'Ganzen Artikel lesen',
    poweredNote: 'Aus unseren Hilfeartikeln erstellt. Details stehen im Artikel.',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = ChatWidgetProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value);
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes cwg-up{from{opacity:0;transform:translateY(8px)}}@keyframes cwg-open{from{opacity:0;transform:translateY(16px) scale(.96)}}@keyframes cwg-dot{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-3px);opacity:1}}@keyframes cwg-ring{0%{box-shadow:0 0 0 0 color-mix(in oklab,var(--primary) 45%,transparent)}100%{box-shadow:0 0 0 14px transparent}}' +
  '@media (prefers-reduced-motion:no-preference){.cwg-up{animation:cwg-up .4s var(--ease-kit) var(--cwg-d,0ms) both}.cwg-open{transform-origin:100% 100%;animation:cwg-open .35s var(--ease-kit) both}.cwg-dot{animation:cwg-dot 1.2s ease-in-out infinite}.cwg-ring{animation:cwg-ring 2.4s ease-out 1s 3}}';

function Style() {
  return (
    <style href="kit-chat-widget" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

function BotMark({ size = 32 }: { size?: number }) {
  return (
    <span aria-hidden className="grid shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2)]" style={{ width: size, height: size }}>
      <Icon name="sparkle" size={Math.round(size * 0.5)} />
    </span>
  );
}

function Person({ initials, size = 32 }: { initials: string; size?: number }) {
  return (
    <span aria-hidden className="grid shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_22%,var(--card))] text-[12px] font-semibold text-success-foreground" style={{ width: size, height: size }}>
      {initials}
    </span>
  );
}

/** The page frame a contained widget sits in */
function Frame({ position, backdrop, children, className }: { position?: 'fixed' | 'contained'; backdrop?: React.ReactNode; children: React.ReactNode; className?: string }) {
  if (position === 'fixed') return <>{children}</>;
  return (
    <section className={cn('bg-surface py-10 sm:py-14', className)}>
      <div className="kit-container">
        <Reveal effect="scale">
          <div className="relative h-[46rem] overflow-hidden rounded-2xl border border-border bg-background shadow-[var(--highlight),var(--shadow-md)] sm:h-[48rem]">
            <div aria-hidden className="pointer-events-none h-full select-none">
              {backdrop}
            </div>
            {children}
          </div>
        </Reveal>
      </div>
    </section>
  );
}

/** The corner the widget lives in: fixed to the window or absolute in the frame */
const corner = (position?: 'fixed' | 'contained') => (position === 'fixed' ? 'fixed bottom-4 right-4 z-50 sm:bottom-6 sm:right-6' : 'absolute bottom-4 right-4 z-10 sm:bottom-5 sm:right-5');

function Header({ name, tagline, t, onClose, agent }: { name: string; tagline?: string; t: Texts; onClose?: () => void; agent?: ChatWidgetProps['agent'] }) {
  return (
    <div className="flex items-center gap-3 border-b border-border px-4 py-3">
      <span className="relative">
        {agent ? <Person initials={agent.initials ?? agent.name.slice(0, 2)} size={36} /> : <BotMark size={36} />}
        <span aria-hidden className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-card bg-success" />
      </span>
      <div className="min-w-0 flex-1">
        <p className="truncate text-[14.5px] font-semibold text-foreground">{agent ? agent.name : name}</p>
        <p className="truncate text-[12px] text-muted-foreground">{agent ? agent.role : tagline ?? t.online}</p>
      </div>
      {onClose && (
        <button type="button" aria-label={t.close} onClick={onClose} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground sm:size-9">
          <Icon name="chevron-down" size={18} />
        </button>
      )}
    </div>
  );
}

/** The panel: a sheet over the whole frame on phones, a card in the corner from sm */
function Panel({ position, children, labelledBy, onClose }: { position?: 'fixed' | 'contained'; children: React.ReactNode; labelledBy?: string; onClose?: () => void }) {
  return (
    <div
      role="dialog"
      aria-labelledby={labelledBy}
      onKeyDown={(e) => {
        // Escape closes the panel and hands the focus back to the launcher
        if (e.key !== 'Escape' || !onClose) return;
        e.stopPropagation();
        const root = e.currentTarget.parentElement;
        onClose();
        requestAnimationFrame(() => root?.querySelector<HTMLButtonElement>('[data-cwg-launcher]')?.focus());
      }}
      className={cn(
        'cwg-open flex flex-col overflow-hidden border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]',
        position === 'fixed' ? 'fixed inset-x-0 bottom-0 z-50 h-[min(40rem,100%)] rounded-t-2xl sm:inset-x-auto sm:bottom-24 sm:right-6 sm:h-[38rem] sm:w-[24rem] sm:rounded-2xl' : 'absolute inset-x-2 bottom-2 top-14 z-10 rounded-2xl sm:inset-x-auto sm:bottom-[5.5rem] sm:right-5 sm:top-auto sm:h-[39rem] sm:w-[23.5rem]',
      )}
    >
      {children}
    </div>
  );
}

function Launcher({ open, onClick, t, position, unread }: { open: boolean; onClick: () => void; t: Texts; position?: 'fixed' | 'contained'; unread?: number }) {
  return (
    <button
      type="button"
      data-cwg-launcher
      aria-label={open ? t.close : t.open}
      aria-expanded={open}
      onClick={onClick}
      className={cn(corner(position), 'grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),var(--shadow-lg)] transition-transform duration-300 ease-[var(--ease-kit)] hover:scale-105 active:scale-95 motion-reduce:hover:scale-100', !open && 'cwg-ring', open && 'max-sm:hidden')}
    >
      <Icon name={open ? 'x' : 'chat'} size={24} className="transition-transform duration-300" />
      {!open && !!unread && <span className="absolute -right-0.5 -top-0.5 grid size-5 place-items-center rounded-full border-2 border-background bg-destructive text-[10.5px] font-semibold text-destructive-foreground">{unread}</span>}
    </button>
  );
}

function Bubble({ m, t, agent, name }: { m: WidgetMessage; t: Texts; agent?: ChatWidgetProps['agent']; name: string }) {
  if (m.from === 'user')
    return (
      <div className="cwg-up flex justify-end">
        <p className="max-w-[82%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-[14px] leading-relaxed text-primary-foreground">{m.text}</p>
      </div>
    );
  return (
    <div className="cwg-up flex items-end gap-2">
      {m.from === 'agent' ? <Person initials={agent?.initials ?? 'AB'} size={26} /> : <BotMark size={26} />}
      <div className="min-w-0 max-w-[85%]">
        <div className="rounded-2xl rounded-bl-md bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] px-3.5 py-2.5 text-[14px] leading-relaxed text-foreground">
          {m.text}
          {!!m.sources?.length && (
            <span className="mt-2 flex flex-col gap-1 border-t border-border pt-2">
              {m.sources.map((s) => (
                <a key={s.title} href={s.href} className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-medium text-foreground underline-offset-4 hover:underline sm:min-h-8">
                  <Icon name="book-open" size={13} className="shrink-0 text-muted-foreground" />
                  <span className="truncate">{s.title}</span>
                </a>
              ))}
            </span>
          )}
        </div>
        {m.time && (
          <p className="mt-1 px-1 text-[11px] text-subtle-foreground">
            {m.from === 'agent' ? agent?.name : name} · {m.time}
          </p>
        )}
      </div>
    </div>
  );
}

function Typing({ t, name }: { t: Texts; name: string }) {
  return (
    <div className="cwg-up flex items-end gap-2" role="status" aria-label={fill(t.typing, { name })}>
      <BotMark size={26} />
      <span className="flex gap-1 rounded-2xl rounded-bl-md bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] px-3.5 py-3.5">
        {[0, 1, 2].map((i) => (
          <span key={i} className="cwg-dot size-1.5 rounded-full bg-muted-foreground" style={{ animationDelay: `${i * 160}ms` }} />
        ))}
      </span>
    </div>
  );
}

function Composer({ t, onSend, disabled }: { t: Texts; onSend: (text: string) => void; disabled?: boolean }) {
  const [text, setText] = useState('');
  return (
    <form
      className="flex items-end gap-2 border-t border-border p-3"
      onSubmit={(e) => {
        e.preventDefault();
        if (!text.trim()) return;
        onSend(text.trim());
        setText('');
      }}
    >
      <label className="min-w-0 flex-1">
        <span className="sr-only">{t.message}</span>
        <textarea
          rows={1}
          value={text}
          disabled={disabled}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
              e.preventDefault();
              e.currentTarget.form?.requestSubmit();
            }
          }}
          placeholder={t.message}
          className="block max-h-28 min-h-11 w-full resize-none rounded-xl border border-border bg-background px-3.5 py-2.5 text-[14px] text-foreground outline-none [field-sizing:content] placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] focus-visible:outline-none"
        />
      </label>
      <button type="submit" aria-label={t.send} disabled={!text.trim() || disabled} className="grid size-11 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground transition-colors disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground">
        <Icon name="send" size={17} />
      </button>
    </form>
  );
}

/** The thread with a reply engine */
function useThread(initial: WidgetMessage[], respond: ChatWidgetProps['respond'], fallback: string) {
  const [messages, setMessages] = useSynced(initial);
  const [typing, setTyping] = useState(false);
  const seq = useRef(0);
  const send = async (text: string) => {
    setMessages((m) => [...m, { id: `u${++seq.current}`, from: 'user', text }]);
    setTyping(true);
    const reply = respond ? await respond(text) : (await new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : 1100)), fallback);
    setTyping(false);
    setMessages((m) => [...m, { id: `b${++seq.current}`, from: 'bot', text: reply }]);
  };
  return { messages, setMessages, typing, setTyping, send };
}

function Scroll({ children, deps }: { children: React.ReactNode; deps: unknown }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (el) el.scrollTo({ top: el.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  }, [deps]);
  return (
    <div ref={ref} className="kit-scrollbar relative min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4 [mask-image:linear-gradient(to_bottom,transparent,black_1.25rem)]">
      {children}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* launcher                                                            */
/* ------------------------------------------------------------------ */

function LauncherHome({ t, name, tagline, greeting, actions = [], articles = [], defaultOpen, position = 'contained', backdrop, respond, className }: P) {
  const [open, setOpen] = useState(!!defaultOpen);
  const [teaser, setTeaser] = useState(!defaultOpen);
  const [chat, setChat] = useState(false);
  const thread = useThread([], respond, t.fallback);
  const id = useId();
  const go = (text: string) => {
    setChat(true);
    thread.send(text);
  };
  return (
    <Frame position={position} backdrop={backdrop} className={className}>
      {teaser && !open && (
        <div className={cn(position === 'fixed' ? 'fixed bottom-24 right-4 z-50 sm:right-6' : 'absolute bottom-[5.5rem] right-4 z-10 sm:right-5', 'cwg-open flex max-w-[16rem] items-start gap-2 rounded-2xl rounded-br-md border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-lg)]')}>
          <button type="button" onClick={() => (setOpen(true), setTeaser(false))} className="text-left text-[13.5px] leading-snug text-foreground">
            {greeting ?? t.teaser}
          </button>
          <button type="button" aria-label={t.dismiss} onClick={() => setTeaser(false)} className="relative -m-1 grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground after:absolute after:-inset-1.5 after:content-[''] hover:bg-accent hover:text-foreground">
            <Icon name="x" size={14} />
          </button>
        </div>
      )}
      {open && (
        <Panel position={position} labelledBy={`${id}-h`} onClose={() => setOpen(false)}>
          {chat ? (
            <>
              <Header name={name} tagline={tagline} t={t} onClose={() => setOpen(false)} />
              <Scroll deps={`${thread.messages.length}-${thread.typing}`}>
                {thread.messages.map((m) => (
                  <Bubble key={m.id} m={m} t={t} name={name} />
                ))}
                {thread.typing && <Typing t={t} name={name} />}
              </Scroll>
              <Composer t={t} onSend={thread.send} disabled={thread.typing} />
            </>
          ) : (
            <>
              <div className="relative overflow-hidden bg-[linear-gradient(160deg,var(--primary),color-mix(in_oklab,var(--primary)_80%,var(--primary-foreground)))] px-5 pb-14 pt-5 text-primary-foreground">
                <div className="flex items-center justify-between">
                  <span className="flex items-center gap-2">
                    <span aria-hidden className="grid size-8 place-items-center rounded-full bg-[rgb(255_255_255/0.2)]">
                      <Icon name="sparkle" size={16} />
                    </span>
                    <span id={`${id}-h`} className="text-[14px] font-semibold">
                      {name}
                    </span>
                  </span>
                  <button type="button" aria-label={t.close} onClick={() => setOpen(false)} className="grid size-11 place-items-center rounded-lg text-primary-foreground/85 hover:bg-[rgb(255_255_255/0.15)] sm:size-9">
                    <Icon name="chevron-down" size={18} />
                  </button>
                </div>
                <p className="cwg-up mt-6 text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em]">{greeting ?? t.teaser}</p>
              </div>
              <div className="kit-scrollbar relative -mt-8 min-h-0 flex-1 space-y-3 overflow-y-auto px-3 pb-3">
                <button type="button" onClick={() => setChat(true)} className="cwg-up flex w-full items-center gap-3 rounded-xl border border-border bg-card p-3.5 text-left focus-visible:-outline-offset-2 shadow-[var(--highlight),var(--shadow-md)] transition-colors hover:border-border-strong" style={{ '--cwg-d': '80ms' } as React.CSSProperties}>
                  <span className="min-w-0 flex-1">
                    <span className="block text-[14px] font-semibold text-foreground">{t.ask}</span>
                    <span className="block text-[12.5px] text-muted-foreground">{tagline ?? t.online}</span>
                  </span>
                  <Icon name="send" size={17} className="text-primary" />
                </button>
                {actions.length > 0 && (
                  <div className="cwg-up grid grid-cols-2 gap-2" style={{ '--cwg-d': '140ms' } as React.CSSProperties}>
                    {actions.map((a) => (
                      <button key={a.id} type="button" onClick={() => go(a.prompt ?? a.label)} className="flex min-h-12 items-center gap-2 rounded-xl border border-border bg-card px-3 py-2.5 text-left focus-visible:-outline-offset-2 text-[13px] font-medium text-foreground transition-colors hover:bg-accent">
                        <Icon name={a.icon ?? 'sparkle'} size={16} className="shrink-0 text-muted-foreground" />
                        <span className="leading-snug">{a.label}</span>
                      </button>
                    ))}
                  </div>
                )}
                {articles.length > 0 && (
                  <div className="cwg-up rounded-xl border border-border bg-card" style={{ '--cwg-d': '200ms' } as React.CSSProperties}>
                    <p className="px-3.5 pb-1 pt-3 text-[12px] font-medium text-subtle-foreground">{t.popular}</p>
                    <ul className="pb-1.5">
                      {articles.map((a) => (
                        <li key={a.id}>
                          <a href={a.href ?? '#'} className="flex min-h-11 items-center gap-3 px-3.5 py-2 text-[13.5px] text-foreground focus-visible:-outline-offset-2 hover:bg-accent/60">
                            <span className="min-w-0 flex-1">
                              <span className="block truncate">{a.title}</span>
                              {a.meta && <span className="block text-[11.5px] text-muted-foreground">{a.meta}</span>}
                            </span>
                            <Icon name="chevron-right" size={15} className="shrink-0 text-muted-foreground" />
                          </a>
                        </li>
                      ))}
                    </ul>
                  </div>
                )}
              </div>
            </>
          )}
        </Panel>
      )}
      <Launcher open={open} onClick={() => (setOpen((o) => !o), setTeaser(false))} t={t} position={position} unread={teaser ? 1 : 0} />
    </Frame>
  );
}

/* ------------------------------------------------------------------ */
/* conversation                                                        */
/* ------------------------------------------------------------------ */

function Conversation({ t, name, tagline, messages = [], suggestions = [], position = 'contained', backdrop, respond, defaultOpen = true, className }: P) {
  const [open, setOpen] = useState(defaultOpen);
  const thread = useThread(messages, respond, t.fallback);
  const id = useId();
  return (
    <Frame position={position} backdrop={backdrop} className={className}>
      {open && (
        <Panel position={position} labelledBy={`${id}-h`} onClose={() => setOpen(false)}>
          <span id={`${id}-h`} className="sr-only">
            {name}
          </span>
          <Header name={name} tagline={tagline} t={t} onClose={() => setOpen(false)} />
          <p className="flex items-start gap-1.5 border-b border-border bg-surface/60 px-4 py-2 text-[11.5px] leading-snug text-muted-foreground">
            <Icon name="info" size={13} className="mt-px shrink-0" />
            {t.aiNote}
          </p>
          <Scroll deps={`${thread.messages.length}-${thread.typing}`}>
            {thread.messages.map((m) => (
              <Bubble key={m.id} m={m} t={t} name={name} />
            ))}
            {thread.typing && <Typing t={t} name={name} />}
          </Scroll>
          {!thread.typing && suggestions.length > 0 && (
            <div className="flex flex-wrap gap-1.5 px-3 pb-1">
              {suggestions.map((s) => (
                <button key={s} type="button" onClick={() => thread.send(s)} className="cwg-up inline-flex min-h-9 items-center rounded-full border border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] px-3 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] max-sm:min-h-11">
                  {s}
                </button>
              ))}
            </div>
          )}
          <Composer t={t} onSend={thread.send} disabled={thread.typing} />
        </Panel>
      )}
      <Launcher open={open} onClick={() => setOpen((o) => !o)} t={t} position={position} />
    </Frame>
  );
}

/* ------------------------------------------------------------------ */
/* handoff                                                             */
/* ------------------------------------------------------------------ */

function Handoff({ t, name, tagline, messages = [], agent, queue, position = 'contained', backdrop, respond, defaultOpen = true, className }: P) {
  const [open, setOpen] = useState(defaultOpen);
  const [stage, setStage] = useState<'queue' | 'joined' | 'left' | 'email'>('queue');
  const [pos, setPos] = useState(queue?.position ?? 2);
  const thread = useThread(messages, respond, t.fallback);
  const [ref, inView] = useInView<HTMLDivElement>();
  const id = useId();

  // The queue moves up, then the person joins and greets
  useEffect(() => {
    if (!inView || stage !== 'queue' || prefersReducedMotion()) return;
    const step = window.setTimeout(() => {
      if (pos > 1) return setPos((p) => p - 1);
      setStage('joined');
      if (agent?.greeting) thread.setMessages((m) => [...m, { id: 'agent-hello', from: 'agent', text: agent.greeting!, time: t.now }]);
    }, 1800);
    return () => window.clearTimeout(step);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [inView, stage, pos]);

  return (
    <Frame position={position} backdrop={backdrop} className={className}>
      {open && (
        <Panel position={position} labelledBy={`${id}-h`} onClose={() => setOpen(false)}>
          <span id={`${id}-h`} className="sr-only">
            {name}
          </span>
          <div ref={ref} className="contents">
            <Header name={name} tagline={tagline} t={t} onClose={() => setOpen(false)} agent={stage === 'joined' ? agent : undefined} />
            <Scroll deps={`${thread.messages.length}-${stage}`}>
              {thread.messages.map((m, i) => (
                <div key={m.id} className="space-y-3">
                  {/* The line goes before the first message of the person */}
                  {agent && m.from === 'agent' && thread.messages.findIndex((x) => x.from === 'agent') === i && (
                    <p className="cwg-up flex items-center gap-2 py-1 text-[12px] text-muted-foreground before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border" role="status">
                      {fill(t.joined, { name: agent.name })}
                    </p>
                  )}
                  <Bubble m={m} t={t} name={name} agent={agent} />
                </div>
              ))}
            </Scroll>
            {stage === 'queue' && (
              <div className="cwg-up border-t border-border p-4" aria-live="polite">
                <div className="flex items-center gap-3">
                  <span className="relative grid size-11 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[17px] font-semibold tabular-nums text-primary">
                    <span key={pos} className="cwg-up">
                      {pos}
                    </span>
                    <span aria-hidden className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-primary [animation-duration:2.4s] motion-reduce:animate-none" />
                  </span>
                  <div className="min-w-0">
                    <p className="text-[14px] font-medium text-foreground">{fill(t.queue, { n: pos })}</p>
                    <p className="text-[12.5px] text-muted-foreground">{queue && fill(t.wait, { wait: queue.wait })}</p>
                  </div>
                </div>
                <div className="mt-3 flex flex-wrap gap-2">
                  <button
                    type="button"
                    onClick={() => (setStage('email'), thread.setMessages((m) => [...m, { id: 'email-ask', from: 'bot', text: t.emailAsk }]))}
                    className="inline-flex h-11 items-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground hover:bg-accent sm:h-9"
                  >
                    <Icon name="mail" size={14} />
                    {t.emailInstead}
                  </button>
                  <button type="button" onClick={() => (setStage('left'), thread.setMessages((m) => [...m, { id: 'left', from: 'bot', text: t.leftNote }]))} className="inline-flex h-11 items-center rounded-lg px-3 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-9">
                    {t.left}
                  </button>
                </div>
              </div>
            )}
            {stage !== 'queue' && <Composer t={t} onSend={thread.send} />}
          </div>
        </Panel>
      )}
      <Launcher open={open} onClick={() => setOpen((o) => !o)} t={t} position={position} />
    </Frame>
  );
}

/* ------------------------------------------------------------------ */
/* lead-capture                                                        */
/* ------------------------------------------------------------------ */

function LeadCapture({ t, name, tagline, messages = [], position = 'contained', backdrop, onLead, defaultOpen = true, className }: P) {
  const [open, setOpen] = useState(defaultOpen);
  const [lead, setLead] = useState({ name: '', email: '' });
  const [agree, setAgree] = useState(false);
  const [error, setError] = useState('');
  const [done, setDone] = useState(false);
  const id = useId();
  const ok = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(lead.email);
  const input = 'h-11 w-full rounded-lg border border-border bg-background px-3 text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] focus-visible:outline-none aria-[invalid=true]:border-destructive';
  return (
    <Frame position={position} backdrop={backdrop} className={className}>
      {open && (
        <Panel position={position} labelledBy={`${id}-h`} onClose={() => setOpen(false)}>
          <span id={`${id}-h`} className="sr-only">
            {name}
          </span>
          <Header name={name} tagline={tagline} t={t} onClose={() => setOpen(false)} />
          <Scroll deps={done}>
            {messages.map((m) => (
              <Bubble key={m.id} m={m} t={t} name={name} />
            ))}
            {done ? (
              <Bubble m={{ id: 'done', from: 'bot', text: fill(t.leadDone, { name: lead.name.split(' ')[0], email: lead.email }) }} t={t} name={name} />
            ) : (
              <form
                noValidate
                className="cwg-up ml-[34px] rounded-2xl border border-border bg-background/60 p-3.5"
                onSubmit={(e) => {
                  e.preventDefault();
                  if (!lead.name.trim() || !lead.email.trim()) return setError(t.required);
                  if (!ok) return setError(t.invalidEmail);
                  setError('');
                  onLead?.(lead);
                  setDone(true);
                }}
              >
                <p className="text-[14px] font-semibold text-foreground">{t.leadTitle}</p>
                <p className="mt-0.5 text-[12.5px] leading-snug text-muted-foreground">{t.leadNote}</p>
                <label className="mt-3 block">
                  <span className="mb-1 block text-[12.5px] font-medium text-foreground">{t.name}</span>
                  <input autoComplete="name" value={lead.name} onChange={(e) => setLead((l) => ({ ...l, name: e.target.value }))} aria-invalid={!!error && !lead.name.trim()} className={input} />
                </label>
                <label className="mt-2.5 block">
                  <span className="mb-1 block text-[12.5px] font-medium text-foreground">{t.email}</span>
                  <input type="email" autoComplete="email" inputMode="email" value={lead.email} onChange={(e) => setLead((l) => ({ ...l, email: e.target.value }))} aria-invalid={!!error && !ok} aria-describedby={error ? `${id}-err` : undefined} className={input} />
                </label>
                <label className="mt-2 flex min-h-11 cursor-pointer items-start gap-2 py-1.5">
                  <input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" />
                  <span className="text-[12px] leading-snug text-muted-foreground">
                    {t.consent}{' '}
                    <a href="#" className="font-medium text-foreground underline underline-offset-2">
                      {t.privacy}
                    </a>
                  </span>
                </label>
                {error && (
                  <p id={`${id}-err`} role="alert" className="cwg-up mt-2 flex items-center gap-1.5 text-[12.5px] text-destructive">
                    <Icon name="alert-circle" size={14} />
                    {error}
                  </p>
                )}
                <button type="submit" disabled={!agree} className="mt-3 inline-flex h-11 w-full items-center justify-center rounded-lg bg-primary text-[13.5px] font-medium text-primary-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground">
                  {t.submit}
                </button>
              </form>
            )}
          </Scroll>
        </Panel>
      )}
      <Launcher open={open} onClick={() => setOpen((o) => !o)} t={t} position={position} />
    </Frame>
  );
}

/* ------------------------------------------------------------------ */
/* help-embed                                                          */
/* ------------------------------------------------------------------ */

function HelpEmbed({ t, name, question = '', answer = '', articles = [], respond, className }: P) {
  const [q, setQ] = useState(question);
  const [asked, setAsked] = useState(question);
  const [reply, setReply] = useState(answer);
  // Every search plays the answer in again, also when the question stays the same
  const [run, setRun] = useState(0);
  const [vote, setVote] = useState<'yes' | 'no' | null>(null);
  return (
    <section className={cn('kit-glow bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className="mx-auto max-w-[44rem]">
          <Reveal>
            <form
              role="search"
              onSubmit={(e) => {
                e.preventDefault();
                if (!q.trim()) return;
                setAsked(q);
                setRun((r) => r + 1);
                setVote(null);
                // A new question gets a new answer when a reply engine is connected
                if (respond) Promise.resolve(respond(q)).then(setReply);
              }}
              className="flex items-center gap-2 rounded-2xl border border-border bg-card p-2 pl-4 shadow-[var(--highlight),var(--shadow-md)] focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]"
            >
              <Icon name="search" size={18} className="shrink-0 text-muted-foreground" />
              <label className="min-w-0 flex-1">
                <span className="sr-only">{t.searchHelp}</span>
                <input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.searchHelp} className="h-11 w-full bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
              </label>
              <button type="submit" className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-xl bg-primary px-4 text-[13.5px] font-medium text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]">
                <Icon name="sparkle" size={15} />
                <span className="max-sm:sr-only">{t.ask}</span>
              </button>
            </form>
          </Reveal>
          {asked && (
            <Reveal delay={120}>
              <div key={`${asked}-${run}`} className="cwg-up mt-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
                <p className="flex items-center gap-2 text-[12.5px] font-medium text-muted-foreground">
                  <BotMark size={22} />
                  {fill(t.answerFrom, { name })}
                </p>
                <p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-foreground">{respond ? reply : answer}</p>
                {articles[0] && (
                  <a href={articles[0].href ?? '#'} className="mt-3 inline-flex min-h-9 items-center gap-1.5 text-[13.5px] font-medium text-foreground underline-offset-4 hover:underline max-sm:min-h-11">
                    {t.readMore}
                    <Icon name="arrow-right" size={14} />
                  </a>
                )}
                <p className="mt-3 text-[12px] text-subtle-foreground">{t.poweredNote}</p>
                <div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4" aria-live="polite">
                  {vote ? (
                    <span className="cwg-up flex items-center gap-1.5 text-[13px] text-success-foreground">
                      <Icon name="check-circle" size={15} />
                      {t.thanks}
                    </span>
                  ) : (
                    <>
                      <span className="mr-1 text-[13px] text-muted-foreground max-sm:w-full">{t.helpful}</span>
                      {(['yes', 'no'] as const).map((v) => (
                        <button key={v} type="button" onClick={() => setVote(v)} className="inline-flex h-11 items-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8">
                          <Icon name={v === 'yes' ? 'thumbs-up' : 'thumbs-down'} size={14} />
                          {t[v]}
                        </button>
                      ))}
                    </>
                  )}
                </div>
              </div>
            </Reveal>
          )}
          {articles.length > 1 && (
            <Reveal delay={200}>
              <ul className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
                {articles.slice(1).map((a) => (
                  <li key={a.id}>
                    <a href={a.href ?? '#'} className="flex h-full min-h-14 items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 text-[13.5px] text-foreground transition-[border-color,transform] duration-300 hover:-translate-y-0.5 hover:border-border-strong motion-reduce:hover:translate-y-0">
                      <Icon name="book-open" size={16} className="shrink-0 text-muted-foreground" />
                      <span className="min-w-0 flex-1">
                        <span className="block leading-snug">{a.title}</span>
                        {a.meta && <span className="block text-[11.5px] text-muted-foreground">{a.meta}</span>}
                      </span>
                    </a>
                  </li>
                ))}
              </ul>
            </Reveal>
          )}
          {vote === 'no' && (
            <div className="cwg-up mt-4 flex flex-col gap-3 rounded-2xl border border-dashed border-border-strong p-4 sm:flex-row sm:items-center sm:justify-between">
              <p className="text-[14px] font-medium text-foreground">{t.stillHelp}</p>
              <a href="#" className="inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground hover:bg-accent sm:h-9">
                <Icon name="mail" size={15} />
                {t.contact}
              </a>
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */

/**
 * A chat widget for websites: the launcher with a help home, a conversation
 * with an AI note and quick replies, the handoff to a person with queue,
 * a lead form inside the chat and an AI answer embedded in a help page.
 */
export function ChatWidget({ variant = 'launcher', labels, ...props }: ChatWidgetProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'conversation':
        return <Conversation {...p} />;
      case 'handoff':
        return <Handoff {...p} />;
      case 'lead-capture':
        return <LeadCapture {...p} />;
      case 'help-embed':
        return <HelpEmbed {...p} />;
      default:
        return <LauncherHome {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Häufige Fragen

Wie installiere ich Chat-Widget?

Mit aiw add @ai-website/chat-widget. Der Befehl legt die Datei components/ui/chat-widget.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und reveal. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Chat-Widget?

5 Varianten, Starter mit Startseite, Gespräch, Übergabe an einen Menschen, Kontaktformular im Chat und Antwort auf der Hilfeseite. Du wählst sie mit der Prop variant, zum Beispiel variant="conversation".

Kann ich die Texte von Chat-Widget übersetzen?

Ja. Chat-Widget bringt 40 eigene Texte auf Deutsch und Englisch mit, etwa „Chat öffnen“, „Chat schließen“ und „Nachricht schreiben“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Chat-Widget einbauen?

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

Kann ich Chat-Widget ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert