@cimplify/sdk 0.99.0 → 0.99.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.d.mts CHANGED
@@ -4331,7 +4331,7 @@ interface ChatWidgetProps {
4331
4331
  className?: string;
4332
4332
  classNames?: ChatWidgetClassNames;
4333
4333
  }
4334
- declare function ChatWidget({ client: clientProp, businessName, greeting, subtitle, starters, placeholder, position, defaultOpen, pollInterval, currency, onGenUIAction, className, classNames, }: ChatWidgetProps): react_jsx_runtime.JSX.Element;
4334
+ declare function ChatWidget({ client: clientProp, businessName: businessNameProp, greeting, subtitle, starters, placeholder, position, defaultOpen, pollInterval, currency: currencyProp, onGenUIAction, className, classNames, }: ChatWidgetProps): react_jsx_runtime.JSX.Element;
4335
4335
 
4336
4336
  interface ChatMarkdownProps {
4337
4337
  text: string;
package/dist/react.d.ts CHANGED
@@ -4331,7 +4331,7 @@ interface ChatWidgetProps {
4331
4331
  className?: string;
4332
4332
  classNames?: ChatWidgetClassNames;
4333
4333
  }
4334
- declare function ChatWidget({ client: clientProp, businessName, greeting, subtitle, starters, placeholder, position, defaultOpen, pollInterval, currency, onGenUIAction, className, classNames, }: ChatWidgetProps): react_jsx_runtime.JSX.Element;
4334
+ declare function ChatWidget({ client: clientProp, businessName: businessNameProp, greeting, subtitle, starters, placeholder, position, defaultOpen, pollInterval, currency: currencyProp, onGenUIAction, className, classNames, }: ChatWidgetProps): react_jsx_runtime.JSX.Element;
4335
4335
 
4336
4336
  interface ChatMarkdownProps {
4337
4337
  text: string;
package/dist/react.js CHANGED
@@ -19852,7 +19852,7 @@ var KEEP_DISMISSED_KEY = "cimplify_keep_chat_dismissed";
19852
19852
  var AT_BOTTOM_SLACK_PX = 48;
19853
19853
  function ChatWidget({
19854
19854
  client: clientProp,
19855
- businessName = "Support",
19855
+ businessName: businessNameProp,
19856
19856
  greeting = "Hi there!",
19857
19857
  subtitle = "Ask us anything \u2014 we typically reply in a few seconds.",
19858
19858
  starters,
@@ -19860,13 +19860,15 @@ function ChatWidget({
19860
19860
  position = "bottom-right",
19861
19861
  defaultOpen = false,
19862
19862
  pollInterval,
19863
- currency,
19863
+ currency: currencyProp,
19864
19864
  onGenUIAction,
19865
19865
  className,
19866
19866
  classNames
19867
19867
  }) {
19868
19868
  const context = useOptionalCimplifyClient();
19869
19869
  const client = clientProp ?? context?.client;
19870
+ const businessName = businessNameProp ?? context?.business?.name ?? "Support";
19871
+ const currency = currencyProp ?? context?.baseCurrency;
19870
19872
  const [open, setOpen] = React14.useState(defaultOpen);
19871
19873
  const [readAt, setReadAt] = React14.useState(null);
19872
19874
  const [attachError, setAttachError] = React14.useState(null);
package/dist/react.mjs CHANGED
@@ -19828,7 +19828,7 @@ var KEEP_DISMISSED_KEY = "cimplify_keep_chat_dismissed";
19828
19828
  var AT_BOTTOM_SLACK_PX = 48;
19829
19829
  function ChatWidget({
19830
19830
  client: clientProp,
19831
- businessName = "Support",
19831
+ businessName: businessNameProp,
19832
19832
  greeting = "Hi there!",
19833
19833
  subtitle = "Ask us anything \u2014 we typically reply in a few seconds.",
19834
19834
  starters,
@@ -19836,13 +19836,15 @@ function ChatWidget({
19836
19836
  position = "bottom-right",
19837
19837
  defaultOpen = false,
19838
19838
  pollInterval,
19839
- currency,
19839
+ currency: currencyProp,
19840
19840
  onGenUIAction,
19841
19841
  className,
19842
19842
  classNames
19843
19843
  }) {
19844
19844
  const context = useOptionalCimplifyClient();
19845
19845
  const client = clientProp ?? context?.client;
19846
+ const businessName = businessNameProp ?? context?.business?.name ?? "Support";
19847
+ const currency = currencyProp ?? context?.baseCurrency;
19846
19848
  const [open, setOpen] = useState(defaultOpen);
19847
19849
  const [readAt, setReadAt] = useState(null);
19848
19850
  const [attachError, setAttachError] = useState(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cimplify/sdk",
3
- "version": "0.99.0",
3
+ "version": "0.99.1",
4
4
  "description": "Cimplify Commerce SDK for storefronts",
5
5
  "keywords": [
6
6
  "cimplify",
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "chat-widget.tsx",
12
- "content": "\"use client\";\n\nimport React, {\n useState,\n useRef,\n useCallback,\n type KeyboardEvent,\n} from \"react\";\nimport type { CimplifyClient } from \"../client\";\nimport { CimplifyError } from \"@cimplify/sdk\";\nimport type { ChatMessage, ChatUploadAttachment, ChatWidgetStarter } from \"../types/support\";\nimport { isOptimistic } from \"./chat-model\";\nimport {\n ChatKitProgram,\n ChatReplyCards,\n MessageBubble,\n Seam,\n chatTimeline,\n dayLabel,\n deliveryState,\n groupPosition,\n proseOf,\n sameDay,\n type GenUIActionEvent,\n} from \"./chat-kit\";\nimport { useChat } from \"./hooks/use-chat\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;\nconst KEEP_DISMISSED_KEY = \"cimplify_keep_chat_dismissed\";\n/** How close to the bottom still counts as reading the live edge. */\nconst AT_BOTTOM_SLACK_PX = 48;\n\n/* ─── Public API ──────────────────────────────────────────── */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: \"bottom-right\" | \"bottom-left\";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n /** Currency for structured product cards (e.g. \"GHS\"). */\n currency?: string;\n /**\n * Receives taps on GenUI buttons and structured cards\n * (`chat:view_product:{id}`, `chat:view_order:{id}`). Without it, cards\n * render read-only and program buttons no-op.\n */\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName = \"Support\",\n greeting = \"Hi there!\",\n subtitle = \"Ask us anything — we typically reply in a few seconds.\",\n starters,\n placeholder = \"Type a message\\u2026\",\n position = \"bottom-right\",\n defaultOpen = false,\n pollInterval,\n currency,\n onGenUIAction,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n\n const [open, setOpen] = useState(defaultOpen);\n const [readAt, setReadAt] = useState<string | null>(null);\n const [attachError, setAttachError] = useState<CimplifyError | null>(null);\n const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);\n\n const {\n messages,\n isLoading,\n isSending,\n awaitingReply,\n seenAt,\n error,\n isActive,\n humanRequested,\n claimedAs,\n signOut,\n send,\n react,\n requestHuman,\n startConversation,\n loadOlder,\n mayHaveOlder,\n openIfExisting,\n } = useChat({ client, starters, pollInterval });\n const showTyping = isSending || awaitingReply;\n\n const attachPhoto = useCallback(\n async (file: File) => {\n if (!client) return;\n setAttachError(null);\n if (!file.type.startsWith(\"image/\")) {\n setAttachError(new CimplifyError(\"invalid_attachment\", \"Only photos can be attached.\"));\n return;\n }\n if (file.size > MAX_ATTACHMENT_BYTES) {\n setAttachError(new CimplifyError(\"attachment_too_large\", \"Photos must be under 5MB.\"));\n return;\n }\n const uploaded = await client.uploads.upload(file);\n if (!uploaded.ok) {\n setAttachError(uploaded.error);\n return;\n }\n const attachment: ChatUploadAttachment = {\n upload_id: uploaded.value.id,\n url: uploaded.value.url,\n mime_type: uploaded.value.content_type,\n filename: uploaded.value.filename,\n size: uploaded.value.size_bytes,\n };\n await send(\"\", { attachments: [attachment] });\n },\n [client, send],\n );\n\n const handleSend = useCallback(\n (content: string) => {\n setAttachError(null);\n const target = replyTo;\n setReplyTo(null);\n return send(content, target ? { replyTo: target } : undefined);\n },\n [send, replyTo],\n );\n\n // Unread badge: incoming messages newer than the watermark snapshotted\n // when the panel last closed.\n const unread = open\n ? 0\n : messages.filter(\n (m) => m.sender_type !== \"customer\" && (!readAt || m.created_at > readAt),\n ).length;\n\n useState(() => {\n if (defaultOpen) void openIfExisting();\n });\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n void openIfExisting();\n }, [openIfExisting]);\n\n const handleClose = useCallback(() => {\n const newest = messages[messages.length - 1];\n setReadAt(newest?.created_at ?? null);\n setOpen(false);\n }, [messages]);\n\n const isLeft = position === \"bottom-left\";\n\n return (\n <div\n className={cn(\n \"fixed bottom-6 z-[9999]\",\n isLeft ? \"left-6\" : \"right-6\",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n client={client}\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n isTyping={showTyping}\n seenAt={seenAt}\n readAt={readAt}\n error={error ?? attachError}\n isActive={isActive}\n isLeft={isLeft}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n replyTo={replyTo}\n claimedAs={claimedAs}\n onSignOut={() => void signOut()}\n onClose={handleClose}\n onSend={handleSend}\n onReact={react}\n onReply={setReplyTo}\n onCancelReply={() => setReplyTo(null)}\n onRequestHuman={() => void requestHuman()}\n onLoadOlder={() => void loadOlder()}\n onAttach={attachPhoto}\n onStartConversation={startConversation}\n currency={currency}\n onGenUIAction={onGenUIAction}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* ─── Bubble ──────────────────────────────────────────────── */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-label=\"Open chat\"\n className={cn(\n \"relative flex h-14 w-14 items-center justify-center rounded-full\",\n \"bg-foreground text-background shadow-lg\",\n \"transition-transform duration-300 ease-out\",\n \"hover:scale-[1.08] active:scale-95\",\n className,\n )}\n >\n <ChatIcon className=\"h-6 w-6\" />\n {unread > 0 && (\n <span\n className={cn(\n \"absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center\",\n \"rounded-full bg-destructive text-[10px] font-bold text-white\",\n \"ring-2 ring-background\",\n )}\n >\n {unread > 9 ? \"9+\" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* ─── Panel ───────────────────────────────────────────────── */\n\nfunction Panel({\n client,\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n isTyping,\n seenAt,\n readAt,\n error,\n isActive,\n isLeft,\n humanRequested,\n mayHaveOlder,\n replyTo,\n claimedAs,\n onSignOut,\n onClose,\n onSend,\n onReact,\n onReply,\n onCancelReply,\n onRequestHuman,\n onLoadOlder,\n onAttach,\n onStartConversation,\n currency,\n onGenUIAction,\n classNames,\n}: {\n client: CimplifyClient | undefined;\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n isTyping: boolean;\n seenAt: string | null;\n readAt: string | null;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n replyTo: ChatMessage | null;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n onSend: (content: string) => Promise<boolean>;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onCancelReply: () => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n onAttach: (file: File) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl\",\n \"w-[400px] max-w-[calc(100vw-3rem)]\",\n \"h-[min(600px,calc(100vh-6rem))]\",\n \"animate-in fade-in slide-in-from-bottom-4 duration-300\",\n isLeft ? \"origin-bottom-left\" : \"origin-bottom-right\",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n claimedAs={claimedAs}\n onSignOut={onSignOut}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n businessName={businessName}\n isLoading={isLoading}\n isSending={isTyping}\n seenAt={seenAt}\n readAt={readAt}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n onReact={onReact}\n onReply={onReply}\n onRequestHuman={onRequestHuman}\n onLoadOlder={onLoadOlder}\n currency={currency}\n onGenUIAction={onGenUIAction}\n className={classNames?.messages}\n />\n )}\n\n {isActive && !claimedAs && client && <KeepConversation client={client} />}\n\n {humanRequested && isActive && (\n <div className=\"flex items-center gap-2 border-t border-border bg-muted/50 px-4 py-2 text-[11px] text-muted-foreground\">\n <span className=\"h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-amber-500\" />\n Waiting for {businessName} — usually replies in minutes\n </div>\n )}\n\n {/* Error */}\n {error != null && (\n <div className=\"border-t border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive\">\n {error.userMessage || \"Something went wrong. Please try again.\"}\n </div>\n )}\n\n {replyTo && (\n <div className=\"flex items-start gap-2 border-t border-border bg-muted/50 px-4 py-2\">\n <span className=\"w-[3px] shrink-0 self-stretch rounded-full bg-foreground/40\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"text-[11px] font-semibold text-foreground\">\n Replying to {replyTo.sender_type === \"customer\" ? \"yourself\" : businessName}\n </div>\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {proseOf(replyTo.content) || \"Photo\"}\n </div>\n </div>\n <button\n type=\"button\"\n aria-label=\"Cancel reply\"\n onClick={onCancelReply}\n className=\"flex-shrink-0 p-0.5 text-muted-foreground hover:text-foreground\"\n >\n <CloseIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n )}\n\n {/* Always present: gating on an active conversation left a visitor who\n wants to type their own question with only the starter buttons. */}\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={isActive ? onSend : async (text) => { await onStartConversation(text); return true; }}\n onAttach={onAttach}\n className={classNames?.input}\n />\n\n\n {/* Footer */}\n <div className=\"flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground\">\n Powered by{\" \"}\n <span className=\"font-semibold text-foreground\">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* ─── Header ──────────────────────────────────────────────── */\n\nfunction Header({\n businessName,\n claimedAs,\n onSignOut,\n onClose,\n className,\n}: {\n businessName: string;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background\",\n className,\n )}\n >\n <div className=\"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-background/15\">\n <ChatIcon className=\"h-5 w-5\" />\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-[15px] font-semibold leading-tight\">\n {businessName}\n </div>\n {claimedAs ? (\n <div className=\"flex items-center gap-1 truncate text-xs text-background/60\">\n <span className=\"truncate\">Chatting as {claimedAs}</span>\n <span aria-hidden=\"true\">·</span>\n <button\n type=\"button\"\n onClick={onSignOut}\n className=\"flex-shrink-0 underline underline-offset-2 hover:text-background\"\n >\n Not you?\n </button>\n </div>\n ) : (\n <div className=\"flex items-center gap-1.5 text-xs text-background/60\">\n <span className=\"h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400\" />\n Online\n </div>\n )}\n </div>\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Close chat\"\n className={cn(\n \"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg\",\n \"bg-background/10 text-background/70 transition-colors hover:bg-background/20 hover:text-background\",\n )}\n >\n <CloseIcon className=\"h-[18px] w-[18px]\" />\n </button>\n </div>\n );\n}\n\n/* ─── Welcome ─────────────────────────────────────────────── */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: \"\\ud83d\\udd0d\", text: \"Help me find a product\" },\n { icon: \"\\ud83d\\uded2\", text: \"Check my cart\" },\n { icon: \"\\ud83d\\udce6\", text: \"Track my order\" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n \"flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center\",\n className,\n )}\n >\n <div className=\"mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted\">\n <ChatIcon className=\"h-7 w-7 text-muted-foreground\" />\n </div>\n <h3 className=\"mb-2 text-xl font-bold text-foreground\">{greeting}</h3>\n <p className=\"mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground\">\n {subtitle}\n </p>\n <div className=\"flex w-full max-w-[300px] flex-col gap-2\">\n {items.map((s) => (\n <button\n key={s.text}\n type=\"button\"\n onClick={() => onStarter(s.text)}\n className={cn(\n \"flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3\",\n \"text-left text-[13px] font-medium text-foreground\",\n \"transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm\",\n )}\n >\n {s.icon && (\n <span className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]\">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* ─── Messages ────────────────────────────────────────────── */\n\nconst QUICK_REACTIONS = [\"👍\", \"❤️\"] as const;\n\nfunction LetterAvatar({ name }: { name: string }) {\n return (\n <span className=\"flex size-6 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background\">\n {name.charAt(0).toUpperCase()}\n </span>\n );\n}\n\nfunction MessageList({\n messages,\n businessName,\n isLoading,\n isSending,\n seenAt,\n readAt,\n humanRequested,\n mayHaveOlder,\n onReact,\n onReply,\n onRequestHuman,\n onLoadOlder,\n currency,\n onGenUIAction,\n className,\n}: {\n messages: ChatMessage[];\n businessName: string;\n isLoading: boolean;\n isSending: boolean;\n seenAt: string | null;\n readAt: string | null;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n}) {\n const scrolledUpRef = useRef(false);\n const timeline = chatTimeline(messages, { readAt, rendersPrograms: true });\n const { bubbles, reactionsByTarget, firstUnreadId, unreadOnOpen, firstAgentMessageId } =\n timeline;\n const lastBubble = bubbles[bubbles.length - 1];\n const offerHuman = !humanRequested && lastBubble?.sender_type === \"bot\" && !isSending;\n\n const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {\n const node = event.currentTarget;\n scrolledUpRef.current =\n node.scrollHeight - node.scrollTop - node.clientHeight >= AT_BOTTOM_SLACK_PX;\n };\n\n return (\n <div\n onScroll={handleScroll}\n style={{ background: \"var(--color-chat-canvas, var(--color-muted, #f3f5f3))\" }}\n className={cn(\"flex flex-1 flex-col gap-0.5 overflow-y-auto px-3.5 py-3.5\", className)}\n >\n {isLoading && messages.length === 0 && (\n <div className=\"flex flex-1 items-center justify-center\">\n <TypingDots />\n </div>\n )}\n\n {mayHaveOlder && (\n <div className=\"flex justify-center pb-1\">\n <button\n type=\"button\"\n onClick={onLoadOlder}\n className=\"rounded-full border border-foreground/[0.06] bg-background px-3.5 py-[7px] text-xs font-semibold text-muted-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]\"\n >\n Show earlier messages\n </button>\n </div>\n )}\n\n {bubbles.map((msg, i) => {\n const prev = bubbles[i - 1];\n const newDay = !prev || !sameDay(prev.created_at, msg.created_at);\n const group = groupPosition(timeline, i);\n const startsRun = group === \"single\" || group === \"first\";\n const delivery = deliveryState(msg, timeline, seenAt, isOptimistic);\n\n return (\n <div\n key={msg.id}\n className={cn(\"flex flex-col gap-1.5\", startsRun && i > 0 && \"mt-2.5\")}\n >\n {newDay && <Seam variant=\"chip\" label={dayLabel(msg.created_at)} />}\n {msg.id === firstUnreadId && (\n <Seam\n variant=\"unread\"\n label={`${unreadOnOpen} new message${unreadOnOpen === 1 ? \"\" : \"s\"}`}\n />\n )}\n {msg.id === firstAgentMessageId && <Seam label=\"A person joined\" />}\n {msg.sender_type === \"system\" ? (\n <Seam label=\"Passing you to the team\" />\n ) : (\n <div className=\"group relative\">\n {msg.sender_type !== \"customer\" && !isOptimistic(msg) && (\n <HoverRail message={msg} onReact={onReact} onReply={onReply} />\n )}\n <MessageBubble\n message={msg}\n reactions={reactionsByTarget.get(msg.id)}\n businessName={businessName}\n group={group}\n delivery={delivery}\n onReply={onReply}\n avatar={<LetterAvatar name={businessName} />}\n renderProgram={(source) => (\n <ChatKitProgram source={source} onAction={onGenUIAction} />\n )}\n renderCards={(target) => (\n <ChatReplyCards message={target} currency={currency} onAction={onGenUIAction} />\n )}\n onQuoteMiss={async () => {\n onLoadOlder();\n return true;\n }}\n />\n </div>\n )}\n </div>\n );\n })}\n\n {isSending && (\n <div className=\"mr-auto mt-1\">\n <TypingDots />\n </div>\n )}\n\n {offerHuman && (\n <div className=\"mt-2\">\n <button\n type=\"button\"\n onClick={onRequestHuman}\n className=\"rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:border-foreground\"\n >\n Talk to a person\n </button>\n </div>\n )}\n\n <div\n key={`${messages.length}:${isSending}`}\n ref={(node) => {\n // Keyed so the callback re-fires per new message/typing change —\n // follows the conversation, but never yanks a reader out of history.\n if (node && !scrolledUpRef.current) node.scrollIntoView({ behavior: \"smooth\" });\n }}\n />\n </div>\n );\n}\n\nfunction HoverRail({\n message,\n onReact,\n onReply,\n}: {\n message: ChatMessage;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n}) {\n return (\n <div className=\"absolute -top-3 right-0 z-10 hidden gap-0.5 rounded-full border border-border bg-background px-1 py-0.5 shadow-sm group-hover:flex\">\n {QUICK_REACTIONS.map((emoji) => (\n <button\n key={emoji}\n type=\"button\"\n aria-label={`React ${emoji}`}\n onClick={() => void onReact(message, emoji)}\n className=\"flex h-6 w-6 items-center justify-center rounded-full text-[13px] hover:bg-muted\"\n >\n {emoji}\n </button>\n ))}\n <button\n type=\"button\"\n aria-label=\"Reply\"\n onClick={() => onReply(message)}\n className=\"flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground\"\n >\n <ReplyIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n );\n}\n\n/* ─── Keep this conversation ──────────────────────────────── */\n\nfunction readKeepDismissed(): boolean {\n try {\n return typeof window !== \"undefined\" && window.localStorage.getItem(KEEP_DISMISSED_KEY) === \"1\";\n } catch {\n return false;\n }\n}\n\nfunction writeKeepDismissed(): void {\n try {\n window.localStorage.setItem(KEEP_DISMISSED_KEY, \"1\");\n } catch {}\n}\n\nfunction KeepConversation({ client }: { client: CimplifyClient }) {\n const [step, setStep] = useState<\"row\" | \"contact\" | \"code\">(\"row\");\n const [dismissed, setDismissed] = useState(readKeepDismissed);\n const [contact, setContact] = useState(\"\");\n const [code, setCode] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [verifyError, setVerifyError] = useState<string | null>(null);\n\n if (dismissed) return null;\n\n const contactType = contact.includes(\"@\") ? \"email\" : \"phone\";\n\n const requestCode = async () => {\n const trimmed = contact.trim();\n if (!trimmed || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.requestOtp({ contact: trimmed, contact_type: contactType });\n setBusy(false);\n if (result.ok) {\n setCode(\"\");\n setStep(\"code\");\n } else {\n setVerifyError(result.error.userMessage || \"Couldn't send a code — try again.\");\n }\n };\n\n const verify = async (value: string) => {\n if (value.length !== 6 || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.verifyOtp({\n contact: contact.trim(),\n contact_type: contactType,\n otp_code: value,\n });\n setBusy(false);\n if (!result.ok) {\n setCode(\"\");\n setVerifyError(result.error.userMessage || \"Invalid or expired code.\");\n }\n };\n\n return (\n <div className=\"flex-shrink-0 border-t border-border bg-muted/50 px-4 py-2.5\">\n {step === \"row\" && (\n <div className=\"flex items-center gap-2.5\">\n <button\n type=\"button\"\n onClick={() => setStep(\"contact\")}\n className=\"flex min-w-0 flex-1 flex-col text-left\"\n >\n <span className=\"text-xs font-semibold text-foreground\">Keep this conversation</span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n Verify your number and it follows you — any device\n </span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Dismiss\"\n onClick={() => {\n writeKeepDismissed();\n setDismissed(true);\n }}\n className=\"flex-shrink-0 p-1 text-muted-foreground hover:text-foreground\"\n >\n <CloseIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n )}\n\n {step === \"contact\" && (\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n inputMode=\"email\"\n value={contact}\n onChange={(e) => setContact(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void requestCode()}\n placeholder=\"Phone or email\"\n autoFocus\n className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground\"\n />\n <button\n type=\"button\"\n onClick={() => void requestCode()}\n disabled={!contact.trim() || busy}\n className=\"h-9 flex-shrink-0 rounded-lg bg-foreground px-3 text-xs font-semibold text-background disabled:opacity-50\"\n >\n {busy ? \"Sending…\" : \"Send code\"}\n </button>\n </div>\n )}\n\n {step === \"code\" && (\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n maxLength={6}\n value={code}\n onChange={(e) => {\n const digits = e.target.value.replace(/\\D/g, \"\").slice(0, 6);\n setCode(digits);\n if (digits.length === 6) void verify(digits);\n }}\n placeholder=\"6-digit code\"\n autoFocus\n className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs tracking-[0.2em] text-foreground outline-none placeholder:tracking-normal placeholder:text-muted-foreground focus:border-foreground\"\n />\n <button\n type=\"button\"\n onClick={() => void requestCode()}\n disabled={busy}\n className=\"h-9 flex-shrink-0 rounded-lg px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground disabled:opacity-50\"\n >\n Resend\n </button>\n </div>\n )}\n\n {verifyError && <p className=\"mt-1.5 text-[11px] text-destructive\">{verifyError}</p>}\n </div>\n );\n}\n\n/* ─── Input ───────────────────────────────────────────────── */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n onAttach,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<boolean>;\n onAttach: (file: File) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState(\"\");\n const [uploading, setUploading] = useState(false);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const handleAttach = useCallback(\n async (file: File) => {\n setUploading(true);\n try {\n await onAttach(file);\n } finally {\n setUploading(false);\n }\n },\n [onAttach],\n );\n\n const handleSend = useCallback(async () => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue(\"\");\n if (textareaRef.current) {\n textareaRef.current.style.height = \"auto\";\n }\n const sent = await onSend(trimmed);\n // A failed send must not eat the message — put it back to retry.\n if (!sent) setValue((current) => current || trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = \"auto\";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n \"flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3\",\n className,\n )}\n >\n <input\n ref={fileRef}\n type=\"file\"\n accept=\"image/*\"\n className=\"hidden\"\n aria-hidden=\"true\"\n tabIndex={-1}\n onChange={(e) => {\n const file = e.target.files?.[0];\n e.target.value = \"\";\n if (file) void handleAttach(file);\n }}\n />\n <button\n type=\"button\"\n aria-label=\"Attach a photo\"\n disabled={uploading}\n onClick={() => fileRef.current?.click()}\n className={cn(\n \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n \"bg-muted text-muted-foreground transition-colors hover:text-foreground\",\n uploading && \"animate-pulse\",\n )}\n >\n <ImageIcon className=\"h-[18px] w-[18px]\" />\n </button>\n <div\n className={cn(\n \"flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1\",\n \"transition-colors focus-within:border-foreground focus-within:bg-background\",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n \"max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2\",\n \"text-sm text-foreground outline-none placeholder:text-muted-foreground\",\n )}\n />\n </div>\n\n <button\n type=\"button\"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label=\"Send message\"\n className={cn(\n \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n \"transition-all duration-200\",\n hasContent && !isSending\n ? \"bg-foreground text-background hover:scale-105 active:scale-95\"\n : \"bg-muted text-muted-foreground\",\n )}\n >\n <SendIcon className=\"h-[18px] w-[18px]\" />\n </button>\n </div>\n );\n}\n\n/* ─── Typing dots ─────────────────────────────────────────── */\n\nfunction TypingDots() {\n return (\n <div className=\"flex items-center gap-1 px-4 py-3\">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className=\"h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40\"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* ─── Icons (inline SVG — no icon library) ────────────────── */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z\" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\" />\n <polygon points=\"22 2 15 22 11 13 2 9 22 2\" />\n </svg>\n );\n}\n\nfunction ImageIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\" />\n <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\" />\n <polyline points=\"21 15 16 10 5 21\" />\n </svg>\n );\n}\n\nfunction ReplyIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <polyline points=\"9 17 4 12 9 7\" />\n <path d=\"M20 18v-2a4 4 0 0 0-4-4H4\" />\n </svg>\n );\n}\n\n"
12
+ "content": "\"use client\";\n\nimport React, {\n useState,\n useRef,\n useCallback,\n type KeyboardEvent,\n} from \"react\";\nimport type { CimplifyClient } from \"../client\";\nimport { CimplifyError } from \"@cimplify/sdk\";\nimport type { ChatMessage, ChatUploadAttachment, ChatWidgetStarter } from \"../types/support\";\nimport { isOptimistic } from \"./chat-model\";\nimport {\n ChatKitProgram,\n ChatReplyCards,\n MessageBubble,\n Seam,\n chatTimeline,\n dayLabel,\n deliveryState,\n groupPosition,\n proseOf,\n sameDay,\n type GenUIActionEvent,\n} from \"./chat-kit\";\nimport { useChat } from \"./hooks/use-chat\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;\nconst KEEP_DISMISSED_KEY = \"cimplify_keep_chat_dismissed\";\n/** How close to the bottom still counts as reading the live edge. */\nconst AT_BOTTOM_SLACK_PX = 48;\n\n/* ─── Public API ──────────────────────────────────────────── */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: \"bottom-right\" | \"bottom-left\";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n /** Currency for structured product cards (e.g. \"GHS\"). */\n currency?: string;\n /**\n * Receives taps on GenUI buttons and structured cards\n * (`chat:view_product:{id}`, `chat:view_order:{id}`). Without it, cards\n * render read-only and program buttons no-op.\n */\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName: businessNameProp,\n greeting = \"Hi there!\",\n subtitle = \"Ask us anything — we typically reply in a few seconds.\",\n starters,\n placeholder = \"Type a message\\u2026\",\n position = \"bottom-right\",\n defaultOpen = false,\n pollInterval,\n currency: currencyProp,\n onGenUIAction,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n // Provider-wrapped hosts get the real identity with zero config.\n const businessName = businessNameProp ?? context?.business?.name ?? \"Support\";\n const currency = currencyProp ?? context?.baseCurrency;\n\n const [open, setOpen] = useState(defaultOpen);\n const [readAt, setReadAt] = useState<string | null>(null);\n const [attachError, setAttachError] = useState<CimplifyError | null>(null);\n const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);\n\n const {\n messages,\n isLoading,\n isSending,\n awaitingReply,\n seenAt,\n error,\n isActive,\n humanRequested,\n claimedAs,\n signOut,\n send,\n react,\n requestHuman,\n startConversation,\n loadOlder,\n mayHaveOlder,\n openIfExisting,\n } = useChat({ client, starters, pollInterval });\n const showTyping = isSending || awaitingReply;\n\n const attachPhoto = useCallback(\n async (file: File) => {\n if (!client) return;\n setAttachError(null);\n if (!file.type.startsWith(\"image/\")) {\n setAttachError(new CimplifyError(\"invalid_attachment\", \"Only photos can be attached.\"));\n return;\n }\n if (file.size > MAX_ATTACHMENT_BYTES) {\n setAttachError(new CimplifyError(\"attachment_too_large\", \"Photos must be under 5MB.\"));\n return;\n }\n const uploaded = await client.uploads.upload(file);\n if (!uploaded.ok) {\n setAttachError(uploaded.error);\n return;\n }\n const attachment: ChatUploadAttachment = {\n upload_id: uploaded.value.id,\n url: uploaded.value.url,\n mime_type: uploaded.value.content_type,\n filename: uploaded.value.filename,\n size: uploaded.value.size_bytes,\n };\n await send(\"\", { attachments: [attachment] });\n },\n [client, send],\n );\n\n const handleSend = useCallback(\n (content: string) => {\n setAttachError(null);\n const target = replyTo;\n setReplyTo(null);\n return send(content, target ? { replyTo: target } : undefined);\n },\n [send, replyTo],\n );\n\n // Unread badge: incoming messages newer than the watermark snapshotted\n // when the panel last closed.\n const unread = open\n ? 0\n : messages.filter(\n (m) => m.sender_type !== \"customer\" && (!readAt || m.created_at > readAt),\n ).length;\n\n useState(() => {\n if (defaultOpen) void openIfExisting();\n });\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n void openIfExisting();\n }, [openIfExisting]);\n\n const handleClose = useCallback(() => {\n const newest = messages[messages.length - 1];\n setReadAt(newest?.created_at ?? null);\n setOpen(false);\n }, [messages]);\n\n const isLeft = position === \"bottom-left\";\n\n return (\n <div\n className={cn(\n \"fixed bottom-6 z-[9999]\",\n isLeft ? \"left-6\" : \"right-6\",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n client={client}\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n isTyping={showTyping}\n seenAt={seenAt}\n readAt={readAt}\n error={error ?? attachError}\n isActive={isActive}\n isLeft={isLeft}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n replyTo={replyTo}\n claimedAs={claimedAs}\n onSignOut={() => void signOut()}\n onClose={handleClose}\n onSend={handleSend}\n onReact={react}\n onReply={setReplyTo}\n onCancelReply={() => setReplyTo(null)}\n onRequestHuman={() => void requestHuman()}\n onLoadOlder={() => void loadOlder()}\n onAttach={attachPhoto}\n onStartConversation={startConversation}\n currency={currency}\n onGenUIAction={onGenUIAction}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* ─── Bubble ──────────────────────────────────────────────── */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-label=\"Open chat\"\n className={cn(\n \"relative flex h-14 w-14 items-center justify-center rounded-full\",\n \"bg-foreground text-background shadow-lg\",\n \"transition-transform duration-300 ease-out\",\n \"hover:scale-[1.08] active:scale-95\",\n className,\n )}\n >\n <ChatIcon className=\"h-6 w-6\" />\n {unread > 0 && (\n <span\n className={cn(\n \"absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center\",\n \"rounded-full bg-destructive text-[10px] font-bold text-white\",\n \"ring-2 ring-background\",\n )}\n >\n {unread > 9 ? \"9+\" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* ─── Panel ───────────────────────────────────────────────── */\n\nfunction Panel({\n client,\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n isTyping,\n seenAt,\n readAt,\n error,\n isActive,\n isLeft,\n humanRequested,\n mayHaveOlder,\n replyTo,\n claimedAs,\n onSignOut,\n onClose,\n onSend,\n onReact,\n onReply,\n onCancelReply,\n onRequestHuman,\n onLoadOlder,\n onAttach,\n onStartConversation,\n currency,\n onGenUIAction,\n classNames,\n}: {\n client: CimplifyClient | undefined;\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n isTyping: boolean;\n seenAt: string | null;\n readAt: string | null;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n replyTo: ChatMessage | null;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n onSend: (content: string) => Promise<boolean>;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onCancelReply: () => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n onAttach: (file: File) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl\",\n \"w-[400px] max-w-[calc(100vw-3rem)]\",\n \"h-[min(600px,calc(100vh-6rem))]\",\n \"animate-in fade-in slide-in-from-bottom-4 duration-300\",\n isLeft ? \"origin-bottom-left\" : \"origin-bottom-right\",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n claimedAs={claimedAs}\n onSignOut={onSignOut}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n businessName={businessName}\n isLoading={isLoading}\n isSending={isTyping}\n seenAt={seenAt}\n readAt={readAt}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n onReact={onReact}\n onReply={onReply}\n onRequestHuman={onRequestHuman}\n onLoadOlder={onLoadOlder}\n currency={currency}\n onGenUIAction={onGenUIAction}\n className={classNames?.messages}\n />\n )}\n\n {isActive && !claimedAs && client && <KeepConversation client={client} />}\n\n {humanRequested && isActive && (\n <div className=\"flex items-center gap-2 border-t border-border bg-muted/50 px-4 py-2 text-[11px] text-muted-foreground\">\n <span className=\"h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-amber-500\" />\n Waiting for {businessName} — usually replies in minutes\n </div>\n )}\n\n {/* Error */}\n {error != null && (\n <div className=\"border-t border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive\">\n {error.userMessage || \"Something went wrong. Please try again.\"}\n </div>\n )}\n\n {replyTo && (\n <div className=\"flex items-start gap-2 border-t border-border bg-muted/50 px-4 py-2\">\n <span className=\"w-[3px] shrink-0 self-stretch rounded-full bg-foreground/40\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"text-[11px] font-semibold text-foreground\">\n Replying to {replyTo.sender_type === \"customer\" ? \"yourself\" : businessName}\n </div>\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {proseOf(replyTo.content) || \"Photo\"}\n </div>\n </div>\n <button\n type=\"button\"\n aria-label=\"Cancel reply\"\n onClick={onCancelReply}\n className=\"flex-shrink-0 p-0.5 text-muted-foreground hover:text-foreground\"\n >\n <CloseIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n )}\n\n {/* Always present: gating on an active conversation left a visitor who\n wants to type their own question with only the starter buttons. */}\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={isActive ? onSend : async (text) => { await onStartConversation(text); return true; }}\n onAttach={onAttach}\n className={classNames?.input}\n />\n\n\n {/* Footer */}\n <div className=\"flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground\">\n Powered by{\" \"}\n <span className=\"font-semibold text-foreground\">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* ─── Header ──────────────────────────────────────────────── */\n\nfunction Header({\n businessName,\n claimedAs,\n onSignOut,\n onClose,\n className,\n}: {\n businessName: string;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background\",\n className,\n )}\n >\n <div className=\"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-background/15\">\n <ChatIcon className=\"h-5 w-5\" />\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-[15px] font-semibold leading-tight\">\n {businessName}\n </div>\n {claimedAs ? (\n <div className=\"flex items-center gap-1 truncate text-xs text-background/60\">\n <span className=\"truncate\">Chatting as {claimedAs}</span>\n <span aria-hidden=\"true\">·</span>\n <button\n type=\"button\"\n onClick={onSignOut}\n className=\"flex-shrink-0 underline underline-offset-2 hover:text-background\"\n >\n Not you?\n </button>\n </div>\n ) : (\n <div className=\"flex items-center gap-1.5 text-xs text-background/60\">\n <span className=\"h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400\" />\n Online\n </div>\n )}\n </div>\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Close chat\"\n className={cn(\n \"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg\",\n \"bg-background/10 text-background/70 transition-colors hover:bg-background/20 hover:text-background\",\n )}\n >\n <CloseIcon className=\"h-[18px] w-[18px]\" />\n </button>\n </div>\n );\n}\n\n/* ─── Welcome ─────────────────────────────────────────────── */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: \"\\ud83d\\udd0d\", text: \"Help me find a product\" },\n { icon: \"\\ud83d\\uded2\", text: \"Check my cart\" },\n { icon: \"\\ud83d\\udce6\", text: \"Track my order\" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n \"flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center\",\n className,\n )}\n >\n <div className=\"mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted\">\n <ChatIcon className=\"h-7 w-7 text-muted-foreground\" />\n </div>\n <h3 className=\"mb-2 text-xl font-bold text-foreground\">{greeting}</h3>\n <p className=\"mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground\">\n {subtitle}\n </p>\n <div className=\"flex w-full max-w-[300px] flex-col gap-2\">\n {items.map((s) => (\n <button\n key={s.text}\n type=\"button\"\n onClick={() => onStarter(s.text)}\n className={cn(\n \"flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3\",\n \"text-left text-[13px] font-medium text-foreground\",\n \"transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm\",\n )}\n >\n {s.icon && (\n <span className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]\">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* ─── Messages ────────────────────────────────────────────── */\n\nconst QUICK_REACTIONS = [\"👍\", \"❤️\"] as const;\n\nfunction LetterAvatar({ name }: { name: string }) {\n return (\n <span className=\"flex size-6 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background\">\n {name.charAt(0).toUpperCase()}\n </span>\n );\n}\n\nfunction MessageList({\n messages,\n businessName,\n isLoading,\n isSending,\n seenAt,\n readAt,\n humanRequested,\n mayHaveOlder,\n onReact,\n onReply,\n onRequestHuman,\n onLoadOlder,\n currency,\n onGenUIAction,\n className,\n}: {\n messages: ChatMessage[];\n businessName: string;\n isLoading: boolean;\n isSending: boolean;\n seenAt: string | null;\n readAt: string | null;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n}) {\n const scrolledUpRef = useRef(false);\n const timeline = chatTimeline(messages, { readAt, rendersPrograms: true });\n const { bubbles, reactionsByTarget, firstUnreadId, unreadOnOpen, firstAgentMessageId } =\n timeline;\n const lastBubble = bubbles[bubbles.length - 1];\n const offerHuman = !humanRequested && lastBubble?.sender_type === \"bot\" && !isSending;\n\n const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {\n const node = event.currentTarget;\n scrolledUpRef.current =\n node.scrollHeight - node.scrollTop - node.clientHeight >= AT_BOTTOM_SLACK_PX;\n };\n\n return (\n <div\n onScroll={handleScroll}\n style={{ background: \"var(--color-chat-canvas, var(--color-muted, #f3f5f3))\" }}\n className={cn(\"flex flex-1 flex-col gap-0.5 overflow-y-auto px-3.5 py-3.5\", className)}\n >\n {isLoading && messages.length === 0 && (\n <div className=\"flex flex-1 items-center justify-center\">\n <TypingDots />\n </div>\n )}\n\n {mayHaveOlder && (\n <div className=\"flex justify-center pb-1\">\n <button\n type=\"button\"\n onClick={onLoadOlder}\n className=\"rounded-full border border-foreground/[0.06] bg-background px-3.5 py-[7px] text-xs font-semibold text-muted-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]\"\n >\n Show earlier messages\n </button>\n </div>\n )}\n\n {bubbles.map((msg, i) => {\n const prev = bubbles[i - 1];\n const newDay = !prev || !sameDay(prev.created_at, msg.created_at);\n const group = groupPosition(timeline, i);\n const startsRun = group === \"single\" || group === \"first\";\n const delivery = deliveryState(msg, timeline, seenAt, isOptimistic);\n\n return (\n <div\n key={msg.id}\n className={cn(\"flex flex-col gap-1.5\", startsRun && i > 0 && \"mt-2.5\")}\n >\n {newDay && <Seam variant=\"chip\" label={dayLabel(msg.created_at)} />}\n {msg.id === firstUnreadId && (\n <Seam\n variant=\"unread\"\n label={`${unreadOnOpen} new message${unreadOnOpen === 1 ? \"\" : \"s\"}`}\n />\n )}\n {msg.id === firstAgentMessageId && <Seam label=\"A person joined\" />}\n {msg.sender_type === \"system\" ? (\n <Seam label=\"Passing you to the team\" />\n ) : (\n <div className=\"group relative\">\n {msg.sender_type !== \"customer\" && !isOptimistic(msg) && (\n <HoverRail message={msg} onReact={onReact} onReply={onReply} />\n )}\n <MessageBubble\n message={msg}\n reactions={reactionsByTarget.get(msg.id)}\n businessName={businessName}\n group={group}\n delivery={delivery}\n onReply={onReply}\n avatar={<LetterAvatar name={businessName} />}\n renderProgram={(source) => (\n <ChatKitProgram source={source} onAction={onGenUIAction} />\n )}\n renderCards={(target) => (\n <ChatReplyCards message={target} currency={currency} onAction={onGenUIAction} />\n )}\n onQuoteMiss={async () => {\n onLoadOlder();\n return true;\n }}\n />\n </div>\n )}\n </div>\n );\n })}\n\n {isSending && (\n <div className=\"mr-auto mt-1\">\n <TypingDots />\n </div>\n )}\n\n {offerHuman && (\n <div className=\"mt-2\">\n <button\n type=\"button\"\n onClick={onRequestHuman}\n className=\"rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:border-foreground\"\n >\n Talk to a person\n </button>\n </div>\n )}\n\n <div\n key={`${messages.length}:${isSending}`}\n ref={(node) => {\n // Keyed so the callback re-fires per new message/typing change —\n // follows the conversation, but never yanks a reader out of history.\n if (node && !scrolledUpRef.current) node.scrollIntoView({ behavior: \"smooth\" });\n }}\n />\n </div>\n );\n}\n\nfunction HoverRail({\n message,\n onReact,\n onReply,\n}: {\n message: ChatMessage;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n}) {\n return (\n <div className=\"absolute -top-3 right-0 z-10 hidden gap-0.5 rounded-full border border-border bg-background px-1 py-0.5 shadow-sm group-hover:flex\">\n {QUICK_REACTIONS.map((emoji) => (\n <button\n key={emoji}\n type=\"button\"\n aria-label={`React ${emoji}`}\n onClick={() => void onReact(message, emoji)}\n className=\"flex h-6 w-6 items-center justify-center rounded-full text-[13px] hover:bg-muted\"\n >\n {emoji}\n </button>\n ))}\n <button\n type=\"button\"\n aria-label=\"Reply\"\n onClick={() => onReply(message)}\n className=\"flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground\"\n >\n <ReplyIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n );\n}\n\n/* ─── Keep this conversation ──────────────────────────────── */\n\nfunction readKeepDismissed(): boolean {\n try {\n return typeof window !== \"undefined\" && window.localStorage.getItem(KEEP_DISMISSED_KEY) === \"1\";\n } catch {\n return false;\n }\n}\n\nfunction writeKeepDismissed(): void {\n try {\n window.localStorage.setItem(KEEP_DISMISSED_KEY, \"1\");\n } catch {}\n}\n\nfunction KeepConversation({ client }: { client: CimplifyClient }) {\n const [step, setStep] = useState<\"row\" | \"contact\" | \"code\">(\"row\");\n const [dismissed, setDismissed] = useState(readKeepDismissed);\n const [contact, setContact] = useState(\"\");\n const [code, setCode] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [verifyError, setVerifyError] = useState<string | null>(null);\n\n if (dismissed) return null;\n\n const contactType = contact.includes(\"@\") ? \"email\" : \"phone\";\n\n const requestCode = async () => {\n const trimmed = contact.trim();\n if (!trimmed || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.requestOtp({ contact: trimmed, contact_type: contactType });\n setBusy(false);\n if (result.ok) {\n setCode(\"\");\n setStep(\"code\");\n } else {\n setVerifyError(result.error.userMessage || \"Couldn't send a code — try again.\");\n }\n };\n\n const verify = async (value: string) => {\n if (value.length !== 6 || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.verifyOtp({\n contact: contact.trim(),\n contact_type: contactType,\n otp_code: value,\n });\n setBusy(false);\n if (!result.ok) {\n setCode(\"\");\n setVerifyError(result.error.userMessage || \"Invalid or expired code.\");\n }\n };\n\n return (\n <div className=\"flex-shrink-0 border-t border-border bg-muted/50 px-4 py-2.5\">\n {step === \"row\" && (\n <div className=\"flex items-center gap-2.5\">\n <button\n type=\"button\"\n onClick={() => setStep(\"contact\")}\n className=\"flex min-w-0 flex-1 flex-col text-left\"\n >\n <span className=\"text-xs font-semibold text-foreground\">Keep this conversation</span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n Verify your number and it follows you — any device\n </span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Dismiss\"\n onClick={() => {\n writeKeepDismissed();\n setDismissed(true);\n }}\n className=\"flex-shrink-0 p-1 text-muted-foreground hover:text-foreground\"\n >\n <CloseIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n )}\n\n {step === \"contact\" && (\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n inputMode=\"email\"\n value={contact}\n onChange={(e) => setContact(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void requestCode()}\n placeholder=\"Phone or email\"\n autoFocus\n className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground\"\n />\n <button\n type=\"button\"\n onClick={() => void requestCode()}\n disabled={!contact.trim() || busy}\n className=\"h-9 flex-shrink-0 rounded-lg bg-foreground px-3 text-xs font-semibold text-background disabled:opacity-50\"\n >\n {busy ? \"Sending…\" : \"Send code\"}\n </button>\n </div>\n )}\n\n {step === \"code\" && (\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n maxLength={6}\n value={code}\n onChange={(e) => {\n const digits = e.target.value.replace(/\\D/g, \"\").slice(0, 6);\n setCode(digits);\n if (digits.length === 6) void verify(digits);\n }}\n placeholder=\"6-digit code\"\n autoFocus\n className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs tracking-[0.2em] text-foreground outline-none placeholder:tracking-normal placeholder:text-muted-foreground focus:border-foreground\"\n />\n <button\n type=\"button\"\n onClick={() => void requestCode()}\n disabled={busy}\n className=\"h-9 flex-shrink-0 rounded-lg px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground disabled:opacity-50\"\n >\n Resend\n </button>\n </div>\n )}\n\n {verifyError && <p className=\"mt-1.5 text-[11px] text-destructive\">{verifyError}</p>}\n </div>\n );\n}\n\n/* ─── Input ───────────────────────────────────────────────── */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n onAttach,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<boolean>;\n onAttach: (file: File) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState(\"\");\n const [uploading, setUploading] = useState(false);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const handleAttach = useCallback(\n async (file: File) => {\n setUploading(true);\n try {\n await onAttach(file);\n } finally {\n setUploading(false);\n }\n },\n [onAttach],\n );\n\n const handleSend = useCallback(async () => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue(\"\");\n if (textareaRef.current) {\n textareaRef.current.style.height = \"auto\";\n }\n const sent = await onSend(trimmed);\n // A failed send must not eat the message — put it back to retry.\n if (!sent) setValue((current) => current || trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = \"auto\";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n \"flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3\",\n className,\n )}\n >\n <input\n ref={fileRef}\n type=\"file\"\n accept=\"image/*\"\n className=\"hidden\"\n aria-hidden=\"true\"\n tabIndex={-1}\n onChange={(e) => {\n const file = e.target.files?.[0];\n e.target.value = \"\";\n if (file) void handleAttach(file);\n }}\n />\n <button\n type=\"button\"\n aria-label=\"Attach a photo\"\n disabled={uploading}\n onClick={() => fileRef.current?.click()}\n className={cn(\n \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n \"bg-muted text-muted-foreground transition-colors hover:text-foreground\",\n uploading && \"animate-pulse\",\n )}\n >\n <ImageIcon className=\"h-[18px] w-[18px]\" />\n </button>\n <div\n className={cn(\n \"flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1\",\n \"transition-colors focus-within:border-foreground focus-within:bg-background\",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n \"max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2\",\n \"text-sm text-foreground outline-none placeholder:text-muted-foreground\",\n )}\n />\n </div>\n\n <button\n type=\"button\"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label=\"Send message\"\n className={cn(\n \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n \"transition-all duration-200\",\n hasContent && !isSending\n ? \"bg-foreground text-background hover:scale-105 active:scale-95\"\n : \"bg-muted text-muted-foreground\",\n )}\n >\n <SendIcon className=\"h-[18px] w-[18px]\" />\n </button>\n </div>\n );\n}\n\n/* ─── Typing dots ─────────────────────────────────────────── */\n\nfunction TypingDots() {\n return (\n <div className=\"flex items-center gap-1 px-4 py-3\">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className=\"h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40\"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* ─── Icons (inline SVG — no icon library) ────────────────── */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z\" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\" />\n <polygon points=\"22 2 15 22 11 13 2 9 22 2\" />\n </svg>\n );\n}\n\nfunction ImageIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\" />\n <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\" />\n <polyline points=\"21 15 16 10 5 21\" />\n </svg>\n );\n}\n\nfunction ReplyIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <polyline points=\"9 17 4 12 9 7\" />\n <path d=\"M20 18v-2a4 4 0 0 0-4-4H4\" />\n </svg>\n );\n}\n\n"
13
13
  }
14
14
  ]
15
15
  }