{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chatbot-widget",
  "title": "Chatbot Widget",
  "description": "An embeddable support chat widget — launcher bubble with an acknowledged unread badge, a panel that keeps its draft when minimised, quick replies, and a rating row on the way out.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "button",
    "input",
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/chatbot-widget.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  CheckCheck,\n  ChevronDown,\n  CircleAlert,\n  LoaderCircle,\n  MessageCircle,\n  RotateCcw,\n  Send,\n  Star,\n  X,\n} from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  ChatbotConfig,\n  ChatbotMessage,\n  ChatbotQuickReply,\n  ChatbotWidgetData,\n} from \"./chatbot-widget.contract\"\n\n/* -------------------------------------------------------------------------- *\n * Chatbot Widget\n *\n * The whole embeddable support surface: a launcher bubble that grows into a\n * panel, and a panel that survives being minimised.\n *\n * Five rules drive the decisions below:\n *\n * 1. Positioning is the host's job. The root is a plain anchor box — the host\n *    passes `fixed bottom-6 right-6 z-50` in production, or `absolute bottom-4\n *    right-4` to park it inside a stage. The widget only decides which edge of\n *    the launcher the panel hangs from.\n * 2. The transcript is server truth, the draft is not. `status` + `messages`\n *    are rendered, never invented; the half-typed sentence lives in local state\n *    so minimising the panel cannot eat it.\n * 3. Unread is acknowledged, not rewritten. Opening the panel records the count\n *    it opened past, so the badge shows what arrived *since* — the widget never\n *    pretends to know the server's read state.\n * 4. Follow the conversation, never fight the reader. New messages scroll into\n *    view only while the user is parked at the bottom; scrolling up to re-read\n *    pins the view until they come back down.\n * 5. Ending a chat is a two-beat exit. \"End chat\" arms the rating row, and that\n *    row IS the confirmation — one tap rates and ends, \"Skip & end\" ends without\n *    a score, \"Keep chatting\" backs out. Ending is one-shot.\n *\n * The narrow-viewport takeover is deliberately pure CSS (`max-sm:` overrides,\n * not a matchMedia listener): no breakpoint state, no hydration flash, and one\n * variant prefix to edit when a project's phone breakpoint is not 640px.\n * -------------------------------------------------------------------------- */\n\n/** Ships with the component through a React 19 hoisted <style> — deduped by href. */\nconst KEYFRAMES = `@keyframes zcw-launcher-in{from{opacity:0;transform:scale(.6) translateY(10px)}to{opacity:1;transform:none}}\n@keyframes zcw-panel-in{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}\n@keyframes zcw-dot{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-3px);opacity:1}}`\n\n/** How close to the bottom still counts as \"reading the newest message\". */\nconst STICK_THRESHOLD_PX = 48\n\n/**\n * Every override the fullscreen takeover needs, as longhands. Variant utilities\n * sort after plain ones, so each of these reliably beats its docked counterpart\n * (`max-sm:bottom-0` over `bottom-full`) — an `inset-0` shorthand would not.\n */\nconst FULLSCREEN_CLASS =\n  \"max-sm:fixed max-sm:top-0 max-sm:right-0 max-sm:bottom-0 max-sm:left-0 max-sm:z-50 max-sm:mb-0 max-sm:h-auto max-sm:max-h-none max-sm:w-auto max-sm:max-w-none max-sm:rounded-none\"\n\nconst RATING_WORDS = [\"Bad\", \"Poor\", \"OK\", \"Good\", \"Great\"] as const\n\nconst SKELETON_ROWS = [\n  { align: \"start\", width: \"w-40\" },\n  { align: \"end\", width: \"w-28\" },\n  { align: \"start\", width: \"w-48\" },\n  { align: \"end\", width: \"w-32\" },\n] as const\n\nconst DOT_DELAY = [\"\", \"[animation-delay:140ms]\", \"[animation-delay:280ms]\"] as const\n\nfunction initialsOf(name: string): string {\n  const parts = name.trim().split(/\\s+/).filter(Boolean)\n  if (parts.length === 0) return \"?\"\n  return parts\n    .slice(0, 2)\n    .map(part => part[0]?.toUpperCase() ?? \"\")\n    .join(\"\")\n}\n\n/* -------------------------------------------------------------------------- *\n * Avatar\n * -------------------------------------------------------------------------- */\n\nfunction BotAvatar({ className, config }: { className?: string; config: ChatbotConfig }) {\n  // The URL that failed, not a boolean: swapping the bot's portrait mid-session\n  // gets a fresh attempt instead of inheriting the previous one's initials.\n  const [failedUrl, setFailedUrl] = React.useState<string | null>(null)\n  const url = config.avatarUrl\n\n  /**\n   * On a pre-rendered page a cached image can fail BEFORE hydration attaches\n   * `onError`; the event never arrives and the broken glyph stays forever. The\n   * ref callback probes for that case synchronously.\n   */\n  const probe = React.useCallback(\n    (node: HTMLImageElement | null) => {\n      if (url && node?.complete && node.naturalWidth === 0) setFailedUrl(url)\n    },\n    [url],\n  )\n\n  const showImage = Boolean(url) && failedUrl !== url\n\n  return (\n    <span\n      className={cn(\n        \"flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted text-[11px] font-medium text-muted-foreground\",\n        className,\n      )}\n    >\n      {showImage && url ? (\n        // eslint-disable-next-line @next/next/no-img-element -- portable registry source, not bound to next/image\n        <img alt=\"\" className=\"size-full object-cover\" onError={() => setFailedUrl(url)} ref={probe} src={url} />\n      ) : (\n        // The bot's name sits right next to it — reading the initials aloud only repeats it.\n        <span aria-hidden=\"true\">{initialsOf(config.botName)}</span>\n      )}\n    </span>\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Transcript pieces\n * -------------------------------------------------------------------------- */\n\nfunction MessageRow({\n  config,\n  message,\n  onRetryMessage,\n}: {\n  config: ChatbotConfig\n  message: ChatbotMessage\n  onRetryMessage?: (id: string) => void\n}) {\n  if (message.from === \"system\") {\n    return <p className=\"px-4 text-center text-[11px] leading-relaxed text-muted-foreground\">{message.text}</p>\n  }\n\n  const outgoing = message.from === \"user\"\n  const failed = outgoing && message.delivery === \"failed\"\n\n  return (\n    <div className={cn(\"flex items-end gap-2\", outgoing && \"flex-row-reverse\")}>\n      {outgoing ? null : <BotAvatar className=\"size-6 text-[10px]\" config={config} />}\n\n      <div className={cn(\"flex min-w-0 max-w-[85%] flex-col gap-1\", outgoing ? \"items-end\" : \"items-start\")}>\n        <div\n          className={cn(\n            \"rounded-2xl px-3 py-2 text-sm leading-relaxed break-words whitespace-pre-wrap\",\n            outgoing\n              ? \"rounded-br-sm bg-primary text-primary-foreground\"\n              : \"rounded-bl-sm border bg-muted text-foreground\",\n            message.delivery === \"sending\" && \"opacity-70\",\n            failed && \"border border-destructive/40\",\n          )}\n        >\n          {message.text}\n        </div>\n\n        <div className=\"flex items-center gap-1.5 px-1 text-[10px] text-muted-foreground\">\n          <span>{message.at}</span>\n\n          {message.delivery === \"sending\" ? (\n            <>\n              <LoaderCircle aria-hidden=\"true\" className=\"size-3 animate-spin motion-reduce:animate-none\" />\n              <span className=\"sr-only\">Sending</span>\n            </>\n          ) : null}\n\n          {message.delivery === \"sent\" ? (\n            <>\n              <CheckCheck aria-hidden=\"true\" className=\"size-3\" />\n              <span className=\"sr-only\">Delivered</span>\n            </>\n          ) : null}\n\n          {failed ? (\n            <>\n              <span className=\"text-destructive\">Not delivered</span>\n              {onRetryMessage ? (\n                <Button\n                  className=\"h-4 px-1 text-[10px]\"\n                  onClick={() => onRetryMessage(message.id)}\n                  size=\"xs\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  Retry\n                </Button>\n              ) : null}\n            </>\n          ) : null}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction TypingBubble({ config }: { config: ChatbotConfig }) {\n  return (\n    <div className=\"flex items-end gap-2\">\n      <BotAvatar className=\"size-6 text-[10px]\" config={config} />\n      <span className=\"flex items-center gap-1 rounded-2xl rounded-bl-sm border bg-muted px-3 py-3\">\n        {DOT_DELAY.map((delay, index) => (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-1.5 rounded-full bg-muted-foreground\",\n              \"animate-[zcw-dot_1s_ease-in-out_infinite] motion-reduce:animate-none\",\n              delay,\n            )}\n            key={index}\n          />\n        ))}\n        <span className=\"sr-only\">{`${config.botName} is typing`}</span>\n      </span>\n    </div>\n  )\n}\n\nfunction TranscriptSkeleton() {\n  return (\n    <div aria-hidden=\"true\" className=\"flex flex-col gap-3\">\n      {SKELETON_ROWS.map((row, index) => (\n        <div className={cn(\"flex\", row.align === \"end\" && \"justify-end\")} key={index}>\n          <div className={cn(\"flex flex-col gap-1.5\", row.align === \"end\" ? \"items-end\" : \"items-start\")}>\n            <div className={cn(\"h-9 animate-pulse rounded-2xl bg-muted\", row.width)} />\n            <div className=\"h-2 w-8 animate-pulse rounded bg-muted\" />\n          </div>\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Rating row — the confirmation step of \"end chat\"\n * -------------------------------------------------------------------------- */\n\nfunction StarRow({\n  onPick,\n  readOnly,\n  score,\n}: {\n  onPick?: (score: number) => void\n  readOnly?: boolean\n  score: number\n}) {\n  // Preview follows the pointer AND the caret, so a keyboard user sees the same\n  // fill a mouse user does before committing.\n  const [preview, setPreview] = React.useState(0)\n\n  if (readOnly) {\n    return (\n      <span aria-label={`Rated ${score} out of 5`} className=\"flex items-center gap-0.5\" role=\"img\">\n        {[1, 2, 3, 4, 5].map(value => (\n          <Star\n            aria-hidden=\"true\"\n            className={cn(\"size-3.5\", value <= score ? \"fill-current text-primary\" : \"text-muted-foreground/50\")}\n            key={value}\n          />\n        ))}\n      </span>\n    )\n  }\n\n  const shown = preview || score\n\n  return (\n    <div aria-label=\"Rate this conversation\" className=\"flex items-center gap-1\" role=\"group\">\n      {[1, 2, 3, 4, 5].map(value => (\n        <button\n          className=\"cursor-pointer rounded-md p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          key={value}\n          onBlur={() => setPreview(0)}\n          onClick={() => onPick?.(value)}\n          onFocus={() => setPreview(value)}\n          onMouseEnter={() => setPreview(value)}\n          onMouseLeave={() => setPreview(0)}\n          type=\"button\"\n        >\n          <Star\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-4 transition-colors\",\n              value <= shown ? \"fill-current text-primary\" : \"text-muted-foreground\",\n            )}\n          />\n          <span className=\"sr-only\">{`${value} out of 5 — ${RATING_WORDS[value - 1]}`}</span>\n        </button>\n      ))}\n      <span aria-hidden=\"true\" className=\"ml-1 text-[11px] text-muted-foreground\">\n        {preview ? RATING_WORDS[preview - 1] : \"Pick a star\"}\n      </span>\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Component\n * -------------------------------------------------------------------------- */\n\ntype ChatbotWidgetPhase = \"chat\" | \"rating\" | \"ended\"\n\nexport interface ChatbotWidgetProps\n  extends ChatbotWidgetData,\n    Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Controlled open state. Omit it and the widget owns its own. */\n  open?: boolean\n  /** Initial open state while uncontrolled. */\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n  /**\n   * Required: the composer is the point of the widget, and a send button that\n   * goes nowhere is a dead affordance. Receives the trimmed text.\n   */\n  onSend: (text: string) => void\n  /** Falls back to `onSend(reply.text ?? reply.label)` when omitted. */\n  onQuickReply?: (reply: ChatbotQuickReply) => void\n  /** Reload a transcript that failed to load. Omit it and the error state shows no button. */\n  onRetry?: () => void\n  /** Re-send one undelivered outgoing message. Omit it and failed bubbles stay read-only. */\n  onRetryMessage?: (id: string) => void\n  /** Enables the \"End chat\" affordance and, with it, the rating exit. */\n  onEndChat?: () => void\n  /** Called with 1–5 just before `onEndChat` when the user rates on the way out. */\n  onRate?: (score: number) => void\n  /** Enables \"Start a new chat\" once the chat has ended. */\n  onRestart?: () => void\n  /** Let the open panel take the whole screen below the `sm` breakpoint (default true). */\n  fullscreenOnNarrow?: boolean\n  /** Which edge of the launcher the panel hangs from. */\n  align?: \"start\" | \"end\"\n  /** Size / radius overrides for the docked panel. */\n  panelClassName?: string\n}\n\nexport const ChatbotWidget = React.forwardRef<HTMLDivElement, ChatbotWidgetProps>(function ChatbotWidget(\n  {\n    status,\n    config,\n    messages,\n    typing = false,\n    errorMessage,\n    open: openProp,\n    defaultOpen = false,\n    onOpenChange,\n    onSend,\n    onQuickReply,\n    onRetry,\n    onRetryMessage,\n    onEndChat,\n    onRate,\n    onRestart,\n    fullscreenOnNarrow = true,\n    align = \"end\",\n    className,\n    panelClassName,\n    onKeyDown,\n    ...props\n  },\n  ref,\n) {\n  const panelId = React.useId()\n  const composerId = React.useId()\n\n  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)\n  const open = openProp ?? uncontrolledOpen\n\n  const [draft, setDraft] = React.useState(\"\")\n  // Mounting open acknowledges the count it mounted with — a widget that is\n  // already on screen has nothing left \"unread\" behind a badge.\n  const [ackUnread, setAckUnread] = React.useState(() => (open ? config.unreadCount : 0))\n  const [phase, setPhase] = React.useState<ChatbotWidgetPhase>(\"chat\")\n  const [rating, setRating] = React.useState<number | null>(null)\n\n  const launcherRef = React.useRef<HTMLButtonElement | null>(null)\n  const inputRef = React.useRef<HTMLInputElement | null>(null)\n  // The rating / ended row, so the exit flow has somewhere to put the caret.\n  const exitRef = React.useRef<HTMLDivElement | null>(null)\n  const scrollRef = React.useRef<HTMLDivElement | null>(null)\n  // Parked at the bottom? Then follow. Scrolled up to re-read? Then stay put.\n  const stickRef = React.useRef(true)\n  // The first paint of an opened panel jumps; later updates glide.\n  const jumpRef = React.useRef(true)\n  // One-shot: a double click must not end the same chat twice.\n  const endedRef = React.useRef(false)\n  // A close asked for the launcher back; the handover waits for the commit.\n  const returnFocusRef = React.useRef(false)\n\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      // Opening IS the acknowledgement: everything counted up to this moment is\n      // now on screen. A host that drives `open` itself should zero its own\n      // `unreadCount` at the same time.\n      if (next) setAckUnread(config.unreadCount)\n      if (openProp === undefined) setUncontrolledOpen(next)\n      onOpenChange?.(next)\n    },\n    [config.unreadCount, onOpenChange, openProp],\n  )\n\n  const closePanel = React.useCallback(() => {\n    setOpen(false)\n    jumpRef.current = true\n    stickRef.current = true\n    // Nominated, not taken here: under the narrow-viewport takeover the launcher\n    // is display:none for as long as `open` is true — which it still is in the\n    // DOM at this point — so focus() would be a silent no-op and the caret would\n    // land on <body> when the panel unmounts. The effect below hands it over\n    // once the close has been committed.\n    returnFocusRef.current = true\n  }, [setOpen])\n\n  const unread = Math.max(0, config.unreadCount - ackUnread)\n\n  /* -- focus: on a real open or close, never on first paint ----------------- */\n\n  const mountedRef = React.useRef(false)\n  React.useEffect(() => {\n    if (!mountedRef.current) {\n      // A panel that is already open at first paint (defaultOpen, SSR) must not\n      // steal the caret from the page it was embedded in.\n      mountedRef.current = true\n      return\n    }\n    if (!open) {\n      if (!returnFocusRef.current) return\n      returnFocusRef.current = false\n      launcherRef.current?.focus()\n      return\n    }\n    if (phase === \"chat\") {\n      inputRef.current?.focus()\n      return\n    }\n    // The button that advanced the exit unmounted with the row it lived in.\n    // Hand the caret to the row that replaced it instead of dropping it on\n    // <body>, where Escape no longer reaches this panel at all.\n    exitRef.current?.focus()\n  }, [open, phase])\n\n  /* -- follow the newest message while the reader is at the bottom ---------- */\n\n  React.useEffect(() => {\n    const node = scrollRef.current\n    if (!open || !node || !stickRef.current) return\n    if (jumpRef.current) {\n      // Opening lands at the bottom instantly; the CSS smooth behaviour would\n      // otherwise animate the whole transcript past the reader.\n      jumpRef.current = false\n      node.style.scrollBehavior = \"auto\"\n      node.scrollTop = node.scrollHeight\n      node.style.scrollBehavior = \"\"\n      return\n    }\n    node.scrollTop = node.scrollHeight\n  }, [open, status, messages.length, typing, phase])\n\n  const handleScroll = () => {\n    const node = scrollRef.current\n    if (!node) return\n    stickRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < STICK_THRESHOLD_PX\n  }\n\n  /* -- actions -------------------------------------------------------------- */\n\n  const live = status === \"ready\" || status === \"empty\"\n  const composerEnabled = live && phase === \"chat\"\n\n  const send = (text: string) => {\n    const value = text.trim()\n    if (!value || !composerEnabled) return\n    setDraft(\"\")\n    stickRef.current = true\n    onSend(value)\n    inputRef.current?.focus()\n  }\n\n  const pickQuickReply = (reply: ChatbotQuickReply) => {\n    if (!composerEnabled) return\n    stickRef.current = true\n    if (onQuickReply) {\n      onQuickReply(reply)\n      return\n    }\n    onSend(reply.text ?? reply.label)\n  }\n\n  const finish = (score: number | null) => {\n    if (endedRef.current) return\n    endedRef.current = true\n    setRating(score)\n    setPhase(\"ended\")\n    if (score !== null) onRate?.(score)\n    onEndChat?.()\n  }\n\n  const restart = () => {\n    endedRef.current = false\n    setRating(null)\n    setPhase(\"chat\")\n    onRestart?.()\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    // Composed, never replaced: the root spreads the host's props, so an\n    // uncomposed handler here would be silently overwritten by their onKeyDown\n    // and Escape would stop closing the panel.\n    onKeyDown?.(event)\n    if (event.defaultPrevented || event.key !== \"Escape\" || !open) return\n    // Swallowed on purpose: a chat panel living inside a host dialog should\n    // close itself first, not take the dialog down with it.\n    event.stopPropagation()\n    closePanel()\n  }\n\n  /* -- derived rendering flags ---------------------------------------------- */\n\n  const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined\n  // Chips are an answer to the bot. While the user's own message is the newest\n  // one — or the bot is mid-sentence — the ball is not in their court.\n  const awaitingUser = !typing && lastMessage?.from !== \"user\"\n  const showQuickReplies = composerEnabled && config.quickReplies.length > 0 && awaitingUser\n\n  const launcherLabel = open\n    ? \"Minimise the chat\"\n    : unread > 0\n      ? `${config.launcherLabel} — ${unread} unread message${unread === 1 ? \"\" : \"s\"}`\n      : config.launcherLabel\n\n  /* -- layout --------------------------------------------------------------- */\n\n  return (\n    <div\n      className={cn(\"relative flex w-fit flex-col\", align === \"end\" ? \"items-end\" : \"items-start\", className)}\n      data-state={open ? \"open\" : \"closed\"}\n      onKeyDown={handleKeyDown}\n      ref={ref}\n      {...props}\n    >\n      <style href=\"zyeon-chatbot-widget\" precedence=\"medium\">\n        {KEYFRAMES}\n      </style>\n\n      {open ? (\n        <section\n          aria-label={`${config.botName} chat`}\n          className={cn(\n            \"flex flex-col overflow-hidden border bg-card text-card-foreground shadow-xl\",\n            \"animate-[zcw-panel-in_180ms_ease-out] motion-reduce:animate-none\",\n            \"absolute bottom-full z-40 mb-3 h-[26rem] max-h-[calc(100vh-6rem)] w-[21rem] max-w-[calc(100vw-2rem)] rounded-2xl\",\n            align === \"end\" ? \"right-0 origin-bottom-right\" : \"left-0 origin-bottom-left\",\n            panelClassName,\n            fullscreenOnNarrow && FULLSCREEN_CLASS,\n          )}\n          id={panelId}\n          role=\"dialog\"\n        >\n          {/* header ---------------------------------------------------------- */}\n          <header className=\"flex shrink-0 items-center gap-2.5 border-b px-3 py-2.5\">\n            <span className=\"relative shrink-0\">\n              <BotAvatar config={config} />\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-card\",\n                  config.online ? \"bg-primary\" : \"bg-muted-foreground\",\n                )}\n              />\n            </span>\n\n            <div className=\"flex min-w-0 flex-1 flex-col\">\n              <p className=\"truncate text-sm leading-tight font-medium\">{config.botName}</p>\n              <p className=\"truncate text-[11px] leading-tight text-muted-foreground\">\n                {config.online ? \"Online\" : \"Away\"}\n                {config.tagline ? ` · ${config.tagline}` : null}\n              </p>\n            </div>\n\n            {onEndChat && phase === \"chat\" ? (\n              <Button onClick={() => setPhase(\"rating\")} size=\"xs\" type=\"button\" variant=\"ghost\">\n                End chat\n              </Button>\n            ) : null}\n\n            {/* The launcher is the canonical expand control; this one only collapses,\n                so it carries a label and nothing else. */}\n            <Button\n              aria-label=\"Minimise the chat\"\n              onClick={closePanel}\n              size=\"icon-xs\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <ChevronDown aria-hidden=\"true\" className={cn(fullscreenOnNarrow && \"max-sm:hidden\")} />\n              {fullscreenOnNarrow ? <X aria-hidden=\"true\" className=\"hidden max-sm:block\" /> : null}\n            </Button>\n          </header>\n\n          {/* transcript ------------------------------------------------------- */}\n          <div\n            aria-label=\"Conversation\"\n            aria-live=\"polite\"\n            className=\"flex min-h-0 flex-1 scroll-smooth flex-col gap-3 overflow-y-auto overscroll-contain px-3 py-3 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none motion-reduce:scroll-auto\"\n            onScroll={handleScroll}\n            ref={scrollRef}\n            role=\"log\"\n            tabIndex={0}\n          >\n            {status === \"loading\" ? (\n              <>\n                <TranscriptSkeleton />\n                <span className=\"sr-only\">Loading the conversation</span>\n              </>\n            ) : null}\n\n            {status === \"error\" ? (\n              <div className=\"m-auto flex max-w-[16rem] flex-col items-center gap-2 text-center\">\n                <CircleAlert aria-hidden=\"true\" className=\"size-5 text-destructive\" />\n                <p className=\"text-sm font-medium\">Couldn&apos;t load this conversation</p>\n                <p className=\"text-xs leading-relaxed text-muted-foreground\">\n                  {errorMessage ?? \"The chat service didn't answer. Nothing you sent has been lost.\"}\n                </p>\n                {onRetry ? (\n                  <Button onClick={onRetry} size=\"sm\" type=\"button\" variant=\"outline\">\n                    <RotateCcw aria-hidden=\"true\" />\n                    Try again\n                  </Button>\n                ) : null}\n              </div>\n            ) : null}\n\n            {status === \"empty\" ? (\n              <div className=\"flex flex-col gap-3\">\n                <div className=\"flex items-end gap-2\">\n                  <BotAvatar className=\"size-6 text-[10px]\" config={config} />\n                  <div className=\"max-w-[85%] rounded-2xl rounded-bl-sm border bg-muted px-3 py-2 text-sm leading-relaxed\">\n                    {config.greeting}\n                  </div>\n                </div>\n                <p className=\"px-8 text-[11px] leading-relaxed text-muted-foreground\">\n                  {config.quickReplies.length > 0\n                    ? \"Pick a topic below, or type your own question.\"\n                    : \"Type your question below to start.\"}\n                </p>\n              </div>\n            ) : null}\n\n            {status === \"ready\"\n              ? messages.map(message => (\n                  <MessageRow config={config} key={message.id} message={message} onRetryMessage={onRetryMessage} />\n                ))\n              : null}\n\n            {live && typing ? <TypingBubble config={config} /> : null}\n          </div>\n\n          {/* quick replies ----------------------------------------------------- */}\n          {showQuickReplies ? (\n            <div\n              aria-label=\"Quick replies\"\n              className=\"flex shrink-0 gap-1.5 overflow-x-auto border-t px-3 py-2\"\n              role=\"group\"\n            >\n              {config.quickReplies.map(reply => (\n                <button\n                  className=\"shrink-0 cursor-pointer rounded-full border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                  key={reply.id}\n                  onClick={() => pickQuickReply(reply)}\n                  type=\"button\"\n                >\n                  {reply.label}\n                </button>\n              ))}\n            </div>\n          ) : null}\n\n          {/* composer / rating / ended ----------------------------------------- */}\n          {phase === \"chat\" ? (\n            <form\n              className=\"flex shrink-0 items-center gap-2 border-t p-2\"\n              onSubmit={event => {\n                event.preventDefault()\n                send(draft)\n              }}\n            >\n              <label className=\"sr-only\" htmlFor={composerId}>\n                {`Message ${config.botName}`}\n              </label>\n              <Input\n                autoComplete=\"off\"\n                className=\"h-9 flex-1\"\n                disabled={!composerEnabled}\n                id={composerId}\n                onChange={event => setDraft(event.target.value)}\n                placeholder={composerEnabled ? config.placeholder : \"Chat unavailable\"}\n                ref={inputRef}\n                value={draft}\n              />\n              <Button\n                aria-label=\"Send message\"\n                disabled={!composerEnabled || draft.trim() === \"\"}\n                size=\"icon-sm\"\n                type=\"submit\"\n              >\n                <Send aria-hidden=\"true\" />\n              </Button>\n            </form>\n          ) : null}\n\n          {phase === \"rating\" ? (\n            <div className=\"flex shrink-0 flex-col gap-2 border-t px-3 py-2.5 outline-none\" ref={exitRef} tabIndex={-1}>\n              <p className=\"text-xs font-medium\">Before you go — how did we do?</p>\n              <StarRow onPick={value => finish(value)} score={0} />\n              <div className=\"flex items-center gap-1\">\n                <Button onClick={() => finish(null)} size=\"xs\" type=\"button\" variant=\"ghost\">\n                  Skip &amp; end\n                </Button>\n                <Button onClick={() => setPhase(\"chat\")} size=\"xs\" type=\"button\" variant=\"ghost\">\n                  Keep chatting\n                </Button>\n              </div>\n            </div>\n          ) : null}\n\n          {phase === \"ended\" ? (\n            <div\n              className=\"flex shrink-0 flex-col items-center gap-2 border-t px-3 py-3 text-center outline-none\"\n              ref={exitRef}\n              tabIndex={-1}\n            >\n              <p className=\"text-xs text-muted-foreground\">\n                {rating === null ? \"Chat ended. Thanks for stopping by.\" : \"Thanks — that helps us get better.\"}\n              </p>\n              {rating !== null ? <StarRow readOnly score={rating} /> : null}\n              {onRestart ? (\n                <Button onClick={restart} size=\"sm\" type=\"button\" variant=\"outline\">\n                  <RotateCcw aria-hidden=\"true\" />\n                  Start a new chat\n                </Button>\n              ) : null}\n            </div>\n          ) : null}\n\n          {/* powered by -------------------------------------------------------- */}\n          {config.poweredBy ? (\n            <p className=\"shrink-0 border-t px-3 py-1.5 text-center text-[10px] text-muted-foreground\">\n              Powered by{\" \"}\n              <a\n                className=\"font-medium underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                href={config.poweredBy.href}\n                rel=\"noreferrer\"\n                target=\"_blank\"\n              >\n                {config.poweredBy.label}\n              </a>\n            </p>\n          ) : null}\n        </section>\n      ) : null}\n\n      {/* launcher ------------------------------------------------------------- */}\n      <button\n        aria-controls={open ? panelId : undefined}\n        aria-expanded={open}\n        aria-label={launcherLabel}\n        className={cn(\n          \"relative flex size-14 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform\",\n          \"hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none\",\n          \"motion-reduce:transition-none motion-reduce:hover:scale-100\",\n          \"animate-[zcw-launcher-in_320ms_ease-out] motion-reduce:animate-none\",\n          // Under the fullscreen takeover the panel owns the viewport and carries\n          // its own close button, so the launcher beneath it is dead weight.\n          open && fullscreenOnNarrow && \"max-sm:hidden\",\n        )}\n        onClick={() => (open ? closePanel() : setOpen(true))}\n        ref={launcherRef}\n        type=\"button\"\n      >\n        {open ? (\n          <ChevronDown aria-hidden=\"true\" className=\"size-6\" />\n        ) : (\n          <MessageCircle aria-hidden=\"true\" className=\"size-6\" />\n        )}\n\n        {!open && unread > 0 ? (\n          <span className=\"absolute -top-0.5 -right-0.5 flex size-5 items-center justify-center\">\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 animate-ping rounded-full bg-primary/50 motion-reduce:hidden\"\n            />\n            <span\n              aria-hidden=\"true\"\n              className=\"relative flex size-5 items-center justify-center rounded-full border-2 border-background bg-destructive text-[10px] leading-none font-semibold text-background tabular-nums\"\n            >\n              {unread > 9 ? \"9+\" : unread}\n            </span>\n          </span>\n        ) : null}\n      </button>\n    </div>\n  )\n})\n\nexport default ChatbotWidget\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/chatbot-widget.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * One tappable chip offered while the ball is in the user's court.\n * `label` is what the chip reads; `text` is what actually gets sent when it\n * differs from the label (\"Track my order\" → \"Where is order #10482?\").\n */\nexport const chatbotQuickReplySchema = z.object({\n  id: z.string(),\n  label: z.string(),\n  text: z.string().optional(),\n})\n\nexport const chatbotMessageSchema = z.object({\n  id: z.string(),\n  /** `system` renders as a centered note, not a bubble (joined, ended, transferred). */\n  from: z.enum([\"bot\", \"user\", \"system\"]),\n  text: z.string(),\n  /**\n   * Display-ready short clock label (\"14:32\"), pre-formatted by the host.\n   * Formatting a Date inside the component would make the server and the client\n   * disagree about the timezone and hydrate differently.\n   */\n  at: z.string(),\n  /** Outgoing only. Incoming messages leave it undefined. */\n  delivery: z.enum([\"sending\", \"sent\", \"failed\"]).optional(),\n})\n\nexport const chatbotConfigSchema = z.object({\n  botName: z.string(),\n  /** One line under the name — \"Answers in about a minute\". */\n  tagline: z.string().optional(),\n  avatarUrl: z.string().optional(),\n  /** Drives the presence dot and the header's Online / Away wording. */\n  online: z.boolean(),\n  /** The first thing the panel says when there is no transcript yet (empty state). */\n  greeting: z.string(),\n  /** Empty array = no chip row at all. */\n  quickReplies: z.array(chatbotQuickReplySchema),\n  /** Badge on the closed launcher. The widget only ever acknowledges it locally. */\n  unreadCount: z.number().int().nonnegative(),\n  /** Accessible name of the launcher bubble — \"Open the support chat\". */\n  launcherLabel: z.string(),\n  /** Composer placeholder. */\n  placeholder: z.string(),\n  /** The \"powered by\" line; `href` must be a real destination, never \"#\". */\n  poweredBy: z.object({ label: z.string(), href: z.string() }).optional(),\n})\n\nexport const chatbotWidgetSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  config: chatbotConfigSchema,\n  messages: z.array(chatbotMessageSchema),\n  /** The bot is composing — renders the three-dot bubble below the transcript. */\n  typing: z.boolean().optional(),\n  /** Replaces the generic wording on the error state. */\n  errorMessage: z.string().optional(),\n})\n\nexport type ChatbotQuickReply = z.infer<typeof chatbotQuickReplySchema>\nexport type ChatbotMessage = z.infer<typeof chatbotMessageSchema>\nexport type ChatbotConfig = z.infer<typeof chatbotConfigSchema>\nexport type ChatbotWidgetData = z.infer<typeof chatbotWidgetSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}