@cimplify/sdk 0.100.6 → 0.102.0

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.
Files changed (70) hide show
  1. package/dist/advanced.d.mts +3 -3
  2. package/dist/advanced.d.ts +3 -3
  3. package/dist/advanced.js +20 -20
  4. package/dist/advanced.mjs +1 -1
  5. package/dist/{chunk-QPRI2NII.js → chunk-2P4KXFB6.js} +6 -0
  6. package/dist/{chunk-4G5UWFC3.mjs → chunk-3V4DVXV3.mjs} +6 -1
  7. package/dist/{chunk-KLJLKT6C.mjs → chunk-4KNFBPV2.mjs} +10 -3
  8. package/dist/{chunk-RQPXLXB2.mjs → chunk-DCMXUKDQ.mjs} +30 -0
  9. package/dist/{chunk-BMFWQNYZ.mjs → chunk-DKLHCZ5M.mjs} +4 -1
  10. package/dist/{chunk-PNTINRAU.js → chunk-EAZGA7LD.js} +30 -0
  11. package/dist/{chunk-BSZJSR62.js → chunk-LOWSMGRT.js} +12 -4
  12. package/dist/{chunk-D3FWJB7Y.js → chunk-YDA46OZ6.js} +50 -47
  13. package/dist/{chunk-3HU5I5VG.js → chunk-YER7DIOM.js} +15 -0
  14. package/dist/{chunk-Z7M6GAHS.mjs → chunk-Z3K2D3TE.mjs} +15 -0
  15. package/dist/{client-BwRAAk4y.d.mts → client-B40KFazg.d.ts} +15 -2
  16. package/dist/{client-DGrvLiJv.d.mts → client-BdErclfU.d.mts} +2 -2
  17. package/dist/{client-CrPglBBz.d.ts → client-BizGfqB7.d.mts} +15 -2
  18. package/dist/{client-CPcZIrjC.d.ts → client-nsiMwuC1.d.ts} +2 -2
  19. package/dist/{index-D-LUYk1Y.d.mts → index-DpmnI__D.d.mts} +3 -3
  20. package/dist/{index-B_RYmvlF.d.ts → index-nEyXWIyR.d.ts} +3 -3
  21. package/dist/index.d.mts +19 -8
  22. package/dist/index.d.ts +19 -8
  23. package/dist/index.js +92 -88
  24. package/dist/index.mjs +4 -4
  25. package/dist/mock/cli.mjs +30 -0
  26. package/dist/mock/library.js +30 -0
  27. package/dist/mock/library.mjs +30 -0
  28. package/dist/mock/msw.js +30 -0
  29. package/dist/mock/msw.mjs +30 -0
  30. package/dist/{payment-BSzy7sly.d.mts → payment-l0rkieEQ.d.mts} +1 -1
  31. package/dist/{payment-jK8ztICx.d.ts → payment-vzNnIi1n.d.ts} +1 -1
  32. package/dist/{price-CJLfcYKd.d.ts → price-BstChV9a.d.ts} +1 -1
  33. package/dist/{price-D96h6c6L.d.mts → price-CS4E5zLS.d.mts} +1 -1
  34. package/dist/{product-CeV8euw4.d.mts → product-Byf0unSy.d.mts} +745 -625
  35. package/dist/{product-CeV8euw4.d.ts → product-Byf0unSy.d.ts} +745 -625
  36. package/dist/react.d.mts +9 -4
  37. package/dist/react.d.ts +9 -4
  38. package/dist/react.js +137 -92
  39. package/dist/react.mjs +58 -13
  40. package/dist/server/evict.d.mts +12 -8
  41. package/dist/server/evict.d.ts +12 -8
  42. package/dist/server/evict.js +53 -7
  43. package/dist/server/evict.mjs +53 -7
  44. package/dist/server/tanstack.d.mts +4 -2
  45. package/dist/server/tanstack.d.ts +4 -2
  46. package/dist/server/tanstack.js +53 -14
  47. package/dist/server/tanstack.mjs +53 -14
  48. package/dist/{server-zyrpzJVn.d.ts → server-Cj40R2ZD.d.ts} +13 -1
  49. package/dist/{server-DRAW2OOX.d.mts → server-DqbZh8Bk.d.mts} +13 -1
  50. package/dist/server.d.mts +4 -4
  51. package/dist/server.d.ts +4 -4
  52. package/dist/server.js +7 -7
  53. package/dist/server.mjs +2 -2
  54. package/dist/testing/msw.d.mts +2 -2
  55. package/dist/testing/msw.d.ts +2 -2
  56. package/dist/testing/msw.js +2 -2
  57. package/dist/testing/msw.mjs +1 -1
  58. package/dist/testing/suite.d.mts +6 -6
  59. package/dist/testing/suite.d.ts +6 -6
  60. package/dist/testing/suite.js +23 -23
  61. package/dist/testing/suite.mjs +4 -4
  62. package/dist/testing.d.mts +52 -21
  63. package/dist/testing.d.ts +52 -21
  64. package/dist/testing.js +83 -79
  65. package/dist/testing.mjs +5 -5
  66. package/dist/utils.d.mts +4 -4
  67. package/dist/utils.d.ts +4 -4
  68. package/package.json +1 -1
  69. package/registry/cart-summary.json +1 -1
  70. package/registry/chat-widget.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cimplify/sdk",
3
- "version": "0.100.6",
3
+ "version": "0.102.0",
4
4
  "description": "Cimplify Commerce SDK for storefronts",
5
5
  "keywords": [
6
6
  "cimplify",
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "path": "cart-summary.tsx",
13
- "content": "\"use client\";\n\nimport React from \"react\";\nimport type { UseCartItem } from \"@cimplify/sdk/react\";\nimport type { DisplayCart } from \"@cimplify/sdk\";\nimport { useCart } from \"@cimplify/sdk/react\";\nimport { getVariantDisplayName } from \"@cimplify/sdk\";\nimport { INPUT_FIELD_TYPE } from \"@cimplify/sdk\";\nimport { currencyCode, type CurrencyCode } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { useProductPrice } from \"@cimplify/sdk/react\";\nimport type { InternalUseCartItem } from \"./hooks/use-cart-types\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CartSummaryClassNames {\n root?: string;\n item?: string;\n itemName?: string;\n itemPrice?: string;\n quantity?: string;\n removeButton?: string;\n totals?: string;\n subtotal?: string;\n tax?: string;\n deliveryFee?: string;\n serviceCharge?: string;\n discount?: string;\n total?: string;\n checkout?: string;\n empty?: string;\n serviceInfo?: string;\n digitalInfo?: string;\n bundleBreakdown?: string;\n compositeBreakdown?: string;\n customerInputs?: string;\n}\n\nexport interface CartSummaryProps {\n /** Optional server cart for extended pricing details (delivery fee, service charge, discounts). */\n cart?: DisplayCart;\n onCheckout?: () => void;\n onItemRemove?: (itemId: string) => void;\n onQuantityChange?: (itemId: string, quantity: number) => void;\n emptyMessage?: string;\n /** Render the totals block (subtotal/tax/total) below the items. Default true. Hosts that show their own totals (drawer, page sidebar) should pass false. */\n showTotals?: boolean;\n /** Render the \"Proceed to Checkout\" button. Defaults to true if `onCheckout` is provided. */\n showCheckoutButton?: boolean;\n className?: string;\n classNames?: CartSummaryClassNames;\n}\n\nfunction CartLineItemRow({\n item,\n onRemove,\n onQuantityChange,\n currency,\n classNames,\n}: {\n item: UseCartItem;\n onRemove: (itemId: string) => void;\n onQuantityChange: (itemId: string, qty: number) => void;\n currency: CurrencyCode;\n classNames?: CartSummaryClassNames;\n}): React.ReactElement {\n const { unitPrice: locallyResolvedUnitPrice } = useProductPrice({\n product: item.product,\n variant: item.variant,\n addOnOptions: item.addOnOptions,\n });\n const pinnedUnitPrice = (item as InternalUseCartItem)._quotedUnitPrice;\n const unitPrice =\n pinnedUnitPrice === undefined ? locallyResolvedUnitPrice : parsePrice(pinnedUnitPrice);\n const hasComposite = item.compositeSelections && item.compositeSelections.length > 0;\n const hasBundle = item.bundleSelections && item.bundleSelections.length > 0;\n\n const isOptimistic = (item as { isOptimistic?: boolean }).isOptimistic === true;\n\n const lineTotal = parsePrice(unitPrice) * item.quantity;\n const imageUrl = item.product.image_url;\n const variantLabel = item.variant ? getVariantDisplayName(item.variant, item.product.name) : null;\n\n return (\n <div\n data-cimplify-cart-item\n data-optimistic={isOptimistic ? \"true\" : undefined}\n className={cn(\n \"group relative flex gap-3 py-4 border-b border-border last:border-b-0 transition-opacity\",\n isOptimistic && \"opacity-60\",\n classNames?.item,\n )}\n >\n {/* Thumbnail */}\n <div\n data-cimplify-cart-item-image\n className=\"relative w-20 h-20 shrink-0 rounded-md overflow-hidden bg-muted\"\n >\n {imageUrl ? (\n <img src={imageUrl} alt=\"\" className=\"w-full h-full object-cover\" loading=\"lazy\" />\n ) : (\n <div className=\"w-full h-full grid place-items-center text-muted-foreground/40\">\n <svg\n className=\"w-6 h-6\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n d=\"M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z\"\n />\n </svg>\n </div>\n )}\n </div>\n\n {/* Info column */}\n <div data-cimplify-cart-item-info className=\"flex-1 min-w-0 flex flex-col\">\n <div className=\"flex items-start justify-between gap-3\">\n <h3\n data-cimplify-cart-item-name\n className={cn(\"text-sm font-medium leading-tight m-0 truncate\", classNames?.itemName)}\n >\n {item.product.name}\n </h3>\n <button\n type=\"button\"\n onClick={() => onRemove(item.id)}\n data-cimplify-cart-item-remove\n className={cn(\n \"shrink-0 -mr-1 -mt-1 grid place-items-center w-7 h-7 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors\",\n classNames?.removeButton,\n )}\n aria-label={`Remove ${item.product.name}`}\n >\n <svg\n className=\"w-3.5 h-3.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n\n {/* Pills row: variant, add-ons, badges */}\n {(variantLabel ||\n (item.addOnOptions && item.addOnOptions.length > 0) ||\n hasComposite ||\n hasBundle) && (\n <div data-cimplify-cart-item-pills className=\"flex flex-wrap gap-1.5 mt-1.5\">\n {variantLabel && (\n <span\n data-cimplify-cart-item-variant\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-foreground\"\n >\n {variantLabel}\n </span>\n )}\n {item.addOnOptions?.map((opt) => (\n <span\n key={opt.id}\n data-cimplify-cart-item-addon\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-muted-foreground\"\n >\n + {opt.name}\n </span>\n ))}\n {(hasComposite || hasBundle) && (\n <span\n data-cimplify-cart-item-badge\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-primary/10 text-[11px] font-semibold uppercase tracking-wider text-primary\"\n >\n {hasComposite ? \"Custom\" : \"Bundle\"}\n </span>\n )}\n </div>\n )}\n\n {/* Special instructions */}\n {item.specialInstructions && (\n <p\n data-cimplify-cart-item-instructions\n className=\"mt-1.5 text-xs italic text-muted-foreground line-clamp-2\"\n >\n \\u201C{item.specialInstructions}\\u201D\n </p>\n )}\n\n {/* Customer input values */}\n {item.customerInputs && item.customerInputs.length > 0 && (\n <dl\n data-cimplify-cart-customer-inputs\n className={cn(\"mt-1.5 text-xs space-y-0.5\", classNames?.customerInputs)}\n >\n {item.customerInputs.map((input) => (\n <div key={input.field_id} data-cimplify-cart-input className=\"flex gap-1.5\">\n <dt data-cimplify-cart-input-label className=\"text-muted-foreground\">\n {input.field_name}:\n </dt>\n <dd data-cimplify-cart-input-value className=\"text-foreground m-0 truncate\">\n {input.field_type === INPUT_FIELD_TYPE.File ||\n input.field_type === INPUT_FIELD_TYPE.Image\n ? String(input.value).split(\"/\").pop() || \"Uploaded file\"\n : String(input.value)}\n </dd>\n </div>\n ))}\n </dl>\n )}\n\n {/* Service: scheduled details */}\n {(item.lineType === \"service\" || item.product.type === \"service\") &&\n item.scheduledStart && (\n <div\n data-cimplify-cart-service-info\n className={cn(\n \"mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground\",\n classNames?.serviceInfo,\n )}\n >\n <svg\n className=\"w-3.5 h-3.5 shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"\n />\n </svg>\n <span>\n {new Date(item.scheduledStart).toLocaleDateString(undefined, {\n month: \"short\",\n day: \"numeric\",\n })}{\" \"}\n at{\" \"}\n {new Date(item.scheduledStart).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })}\n </span>\n {item.serviceStatus && (\n <span\n data-status={item.serviceStatus}\n className=\"ml-auto px-1.5 py-0.5 rounded-full bg-muted text-[10px] uppercase tracking-wider\"\n >\n {item.serviceStatus}\n </span>\n )}\n </div>\n )}\n\n {/* Digital: instant-delivery badge */}\n {(item.lineType === \"digital\" || item.product.type === \"digital\") && (\n <div\n data-cimplify-cart-digital-info\n className={cn(\n \"mt-1.5 flex items-center gap-1 text-xs text-muted-foreground\",\n classNames?.digitalInfo,\n )}\n >\n <svg\n className=\"w-3.5 h-3.5 shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n </svg>\n <span>Instant digital delivery</span>\n </div>\n )}\n\n {/* Bundle / composite component breakdown */}\n {(item.bundleResolved || item.compositeResolved) && (\n <ul\n data-cimplify-cart-composite-breakdown={item.compositeResolved ? \"\" : undefined}\n data-cimplify-cart-bundle-breakdown={item.bundleResolved ? \"\" : undefined}\n className={cn(\n \"mt-1.5 pl-3 border-l border-border space-y-0.5 text-xs text-muted-foreground\",\n item.compositeResolved ? classNames?.compositeBreakdown : classNames?.bundleBreakdown,\n )}\n >\n {(\n (item.bundleResolved?.selections ??\n item.compositeResolved?.selections ??\n []) as Array<{\n component_id?: string;\n product_name?: string;\n component_name?: string;\n quantity: number;\n product_type?: string;\n }>\n ).map((sel) => {\n const name = sel.product_name ?? sel.component_name ?? \"\";\n const compId = sel.component_id ?? `${name}-${sel.quantity}`;\n return (\n <li\n key={compId}\n data-product-type={sel.product_type}\n className=\"flex items-baseline gap-1.5 m-0\"\n >\n <span className=\"font-medium text-foreground/80\">{sel.quantity}\\u00D7</span>\n <span className=\"truncate\">{name}</span>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Bottom row: line price + qty stepper */}\n <div className=\"mt-3 flex items-center justify-between gap-3\">\n <Price\n amount={lineTotal}\n currency={currency}\n className={cn(\"text-sm font-semibold tabular-nums\", classNames?.itemPrice)}\n />\n <div data-cimplify-cart-item-controls className={classNames?.quantity}>\n <QuantitySelector\n value={item.quantity}\n onChange={(qty) => onQuantityChange(item.id, qty)}\n min={0}\n />\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/**\n * CartSummary — renders cart line items + totals.\n *\n * NOT a drawer or modal — just the cart content. Templates wrap this in\n * their own drawer/modal shell with animations.\n */\nexport function CartSummary({\n cart: serverCart,\n onCheckout,\n onItemRemove,\n onQuantityChange,\n emptyMessage = \"Your cart is empty\",\n showTotals = true,\n showCheckoutButton,\n className,\n classNames,\n}: CartSummaryProps): React.ReactElement {\n const {\n items,\n itemCount,\n subtotal,\n tax,\n total,\n isEmpty,\n isOptimistic,\n pendingOpCount,\n currency,\n removeItem,\n updateQuantity,\n } = useCart();\n const displayCurrency = serverCart?.currency ?? currencyCode(currency);\n\n const deliveryFee = serverCart?.delivery_fee;\n const serviceCharge = serverCart?.service_charge;\n const totalDiscounts = serverCart?.total_discounts;\n\n const handleRemove = (itemId: string) => {\n if (onItemRemove) {\n onItemRemove(itemId);\n } else {\n void removeItem(itemId);\n }\n };\n\n const handleQuantityChange = (itemId: string, qty: number) => {\n if (onQuantityChange) {\n onQuantityChange(itemId, qty);\n } else {\n void updateQuantity(itemId, qty);\n }\n };\n\n return (\n <div\n data-cimplify-cart-summary\n data-optimistic={isOptimistic ? \"true\" : undefined}\n data-pending-ops={pendingOpCount > 0 ? pendingOpCount : undefined}\n className={cn(className, classNames?.root)}\n >\n {isEmpty ? (\n <div data-cimplify-cart-empty className={classNames?.empty}>\n <p>{emptyMessage}</p>\n </div>\n ) : (\n <>\n {/* Line items */}\n <div data-cimplify-cart-items>\n {items.map((item) => (\n <CartLineItemRow\n key={item.id}\n item={item}\n onRemove={handleRemove}\n onQuantityChange={handleQuantityChange}\n currency={displayCurrency}\n classNames={classNames}\n />\n ))}\n </div>\n\n {/* Totals */}\n {showTotals && (\n <div\n data-cimplify-cart-totals\n className={cn(\n \"mt-6 pt-4 border-t border-border space-y-2 text-sm\",\n classNames?.totals,\n )}\n >\n <div\n data-cimplify-cart-subtotal\n className={cn(\"flex items-baseline justify-between\", classNames?.subtotal)}\n >\n <span className=\"text-muted-foreground\">\n Subtotal ({itemCount} {itemCount === 1 ? \"item\" : \"items\"})\n </span>\n <Price amount={subtotal} currency={displayCurrency} className=\"tabular-nums\" />\n </div>\n\n {totalDiscounts != null && parsePrice(totalDiscounts) > 0 && (\n <div\n data-cimplify-cart-discount\n className={cn(\"flex items-baseline justify-between\", classNames?.discount)}\n >\n <span className=\"text-muted-foreground\">Discount</span>\n <Price\n amount={totalDiscounts}\n currency={displayCurrency}\n prefix=\"-\"\n className=\"tabular-nums text-primary\"\n />\n </div>\n )}\n\n {deliveryFee != null && (\n <div\n data-cimplify-cart-delivery-fee\n className={cn(\"flex items-baseline justify-between\", classNames?.deliveryFee)}\n >\n <span className=\"text-muted-foreground\">Delivery</span>\n {parsePrice(deliveryFee) > 0 ? (\n <Price\n amount={deliveryFee}\n currency={displayCurrency}\n className=\"tabular-nums\"\n />\n ) : (\n <span className=\"font-medium text-primary\">Free</span>\n )}\n </div>\n )}\n\n {serviceCharge != null && parsePrice(serviceCharge) > 0 && (\n <div\n data-cimplify-cart-service-charge\n className={cn(\"flex items-baseline justify-between\", classNames?.serviceCharge)}\n >\n <span className=\"text-muted-foreground\">Service charge</span>\n <Price\n amount={serviceCharge}\n currency={displayCurrency}\n className=\"tabular-nums\"\n />\n </div>\n )}\n\n <div\n data-cimplify-cart-tax\n className={cn(\"flex items-baseline justify-between\", classNames?.tax)}\n >\n <span className=\"text-muted-foreground\">Tax</span>\n <Price amount={tax} currency={displayCurrency} className=\"tabular-nums\" />\n </div>\n\n <div\n data-cimplify-cart-total\n className={cn(\n \"flex items-baseline justify-between pt-3 mt-1 border-t border-border\",\n classNames?.total,\n )}\n >\n <span className=\"font-semibold\">Total</span>\n <Price\n amount={total}\n currency={displayCurrency}\n className=\"text-base font-bold tabular-nums\"\n />\n </div>\n </div>\n )}\n\n {(showCheckoutButton ?? Boolean(onCheckout)) && onCheckout && (\n <button\n type=\"button\"\n onClick={onCheckout}\n data-cimplify-cart-checkout\n className={cn(\n \"mt-4 w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all\",\n classNames?.checkout,\n )}\n >\n Proceed to Checkout\n </button>\n )}\n </>\n )}\n </div>\n );\n}\n"
13
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport type { UseCartItem } from \"@cimplify/sdk/react\";\nimport type { DisplayCart } from \"@cimplify/sdk\";\nimport { useCart } from \"@cimplify/sdk/react\";\nimport { getVariantDisplayName } from \"@cimplify/sdk\";\nimport { INPUT_FIELD_TYPE } from \"@cimplify/sdk\";\nimport { currencyCode, type CurrencyCode } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { useProductPrice } from \"@cimplify/sdk/react\";\nimport type { InternalUseCartItem } from \"./hooks/use-cart-types\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CartSummaryClassNames {\n root?: string;\n item?: string;\n itemName?: string;\n itemPrice?: string;\n quantity?: string;\n removeButton?: string;\n totals?: string;\n subtotal?: string;\n tax?: string;\n deliveryFee?: string;\n serviceCharge?: string;\n discount?: string;\n total?: string;\n checkout?: string;\n empty?: string;\n serviceInfo?: string;\n digitalInfo?: string;\n bundleBreakdown?: string;\n compositeBreakdown?: string;\n customerInputs?: string;\n}\n\nexport interface CartSummaryProps {\n /** Optional server cart for extended pricing details (delivery fee, service charge, discounts). */\n cart?: DisplayCart;\n onCheckout?: () => void;\n onItemRemove?: (itemId: string) => void;\n onQuantityChange?: (itemId: string, quantity: number) => void;\n emptyMessage?: string;\n /** Render the totals block (subtotal/tax/total) below the items. Default true. Hosts that show their own totals (drawer, page sidebar) should pass false. */\n showTotals?: boolean;\n /** Render the \"Proceed to Checkout\" button. Defaults to true if `onCheckout` is provided. */\n showCheckoutButton?: boolean;\n className?: string;\n classNames?: CartSummaryClassNames;\n}\n\nfunction CartLineItemRow({\n item,\n onRemove,\n onQuantityChange,\n currency,\n classNames,\n}: {\n item: UseCartItem;\n onRemove: (itemId: string) => void;\n onQuantityChange: (itemId: string, qty: number) => void;\n currency: CurrencyCode;\n classNames?: CartSummaryClassNames;\n}): React.ReactElement {\n const { unitPrice: locallyResolvedUnitPrice } = useProductPrice({\n product: item.product,\n variant: item.variant,\n addOnOptions: item.addOnOptions,\n });\n const pinnedUnitPrice = (item as InternalUseCartItem)._quotedUnitPrice;\n const unitPrice =\n pinnedUnitPrice === undefined ? locallyResolvedUnitPrice : parsePrice(pinnedUnitPrice);\n const hasComposite = item.compositeSelections && item.compositeSelections.length > 0;\n const hasBundle = item.bundleSelections && item.bundleSelections.length > 0;\n\n const isOptimistic = (item as { isOptimistic?: boolean }).isOptimistic === true;\n\n const lineTotal = parsePrice(unitPrice) * item.quantity;\n const imageUrl = item.product.image_url;\n const variantLabel = item.variant ? getVariantDisplayName(item.variant, item.product.name) : null;\n\n return (\n <div\n data-cimplify-cart-item\n data-optimistic={isOptimistic ? \"true\" : undefined}\n className={cn(\n \"group relative flex gap-3 py-4 border-b border-border last:border-b-0 transition-opacity\",\n isOptimistic && \"opacity-60\",\n classNames?.item,\n )}\n >\n {/* Thumbnail */}\n <div\n data-cimplify-cart-item-image\n className=\"relative w-20 h-20 shrink-0 rounded-md overflow-hidden bg-muted\"\n >\n {imageUrl ? (\n <img src={imageUrl} alt=\"\" className=\"w-full h-full object-cover\" loading=\"lazy\" />\n ) : (\n <div className=\"w-full h-full grid place-items-center text-muted-foreground/40\">\n <svg\n className=\"w-6 h-6\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n d=\"M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z\"\n />\n </svg>\n </div>\n )}\n </div>\n\n {/* Info column */}\n <div data-cimplify-cart-item-info className=\"flex-1 min-w-0 flex flex-col\">\n <div className=\"flex items-start justify-between gap-3\">\n <h3\n data-cimplify-cart-item-name\n className={cn(\"text-sm font-medium leading-tight m-0 truncate\", classNames?.itemName)}\n >\n {item.product.name}\n </h3>\n <button\n type=\"button\"\n onClick={() => onRemove(item.id)}\n data-cimplify-cart-item-remove\n className={cn(\n \"shrink-0 -mr-1 -mt-1 grid place-items-center w-7 h-7 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors\",\n classNames?.removeButton,\n )}\n aria-label={`Remove ${item.product.name}`}\n >\n <svg\n className=\"w-3.5 h-3.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n\n {/* Pills row: variant, add-ons, badges */}\n {(variantLabel ||\n (item.addOnOptions && item.addOnOptions.length > 0) ||\n hasComposite ||\n hasBundle) && (\n <div data-cimplify-cart-item-pills className=\"flex flex-wrap gap-1.5 mt-1.5\">\n {variantLabel && (\n <span\n data-cimplify-cart-item-variant\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-foreground\"\n >\n {variantLabel}\n </span>\n )}\n {item.addOnOptions?.map((opt) => (\n <span\n key={opt.id}\n data-cimplify-cart-item-addon\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-muted-foreground\"\n >\n + {opt.name}\n </span>\n ))}\n {(hasComposite || hasBundle) && (\n <span\n data-cimplify-cart-item-badge\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-primary/10 text-[11px] font-semibold uppercase tracking-wider text-primary\"\n >\n {hasComposite ? \"Custom\" : \"Bundle\"}\n </span>\n )}\n </div>\n )}\n\n {/* Special instructions */}\n {item.specialInstructions && (\n <p\n data-cimplify-cart-item-instructions\n className=\"mt-1.5 text-xs italic text-muted-foreground line-clamp-2\"\n >\n \\u201C{item.specialInstructions}\\u201D\n </p>\n )}\n\n {/* Customer input values */}\n {item.customerInputs && item.customerInputs.length > 0 && (\n <dl\n data-cimplify-cart-customer-inputs\n className={cn(\"mt-1.5 text-xs space-y-0.5\", classNames?.customerInputs)}\n >\n {item.customerInputs.map((input) => (\n <div key={input.field_id} data-cimplify-cart-input className=\"flex gap-1.5\">\n <dt data-cimplify-cart-input-label className=\"text-muted-foreground\">\n {input.field_name}:\n </dt>\n <dd data-cimplify-cart-input-value className=\"text-foreground m-0 truncate\">\n {input.field_type === INPUT_FIELD_TYPE.File ||\n input.field_type === INPUT_FIELD_TYPE.Image\n ? String(input.value).split(\"/\").pop() || \"Uploaded file\"\n : String(input.value)}\n </dd>\n </div>\n ))}\n </dl>\n )}\n\n {/* Service: scheduled details */}\n {(item.lineType === \"service\" || item.product.type === \"service\") &&\n item.scheduledStart && (\n <div\n data-cimplify-cart-service-info\n className={cn(\n \"mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground\",\n classNames?.serviceInfo,\n )}\n >\n <svg\n className=\"w-3.5 h-3.5 shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"\n />\n </svg>\n <span>\n {new Date(item.scheduledStart).toLocaleDateString(undefined, {\n month: \"short\",\n day: \"numeric\",\n })}{\" \"}\n at{\" \"}\n {new Date(item.scheduledStart).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })}\n </span>\n {item.serviceStatus && (\n <span\n data-status={item.serviceStatus}\n className=\"ml-auto px-1.5 py-0.5 rounded-full bg-muted text-[10px] uppercase tracking-wider\"\n >\n {item.serviceStatus}\n </span>\n )}\n </div>\n )}\n\n {/* Digital: instant-delivery badge */}\n {(item.lineType === \"digital\" || item.product.type === \"digital\") && (\n <div\n data-cimplify-cart-digital-info\n className={cn(\n \"mt-1.5 flex items-center gap-1 text-xs text-muted-foreground\",\n classNames?.digitalInfo,\n )}\n >\n <svg\n className=\"w-3.5 h-3.5 shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n </svg>\n <span>Instant digital delivery</span>\n </div>\n )}\n\n {/* Bundle / composite component breakdown */}\n {(item.bundleResolved || item.compositeResolved) && (\n <ul\n data-cimplify-cart-composite-breakdown={item.compositeResolved ? \"\" : undefined}\n data-cimplify-cart-bundle-breakdown={item.bundleResolved ? \"\" : undefined}\n className={cn(\n \"mt-1.5 pl-3 border-l border-border space-y-0.5 text-xs text-muted-foreground\",\n item.compositeResolved ? classNames?.compositeBreakdown : classNames?.bundleBreakdown,\n )}\n >\n {(\n (item.bundleResolved?.selections ??\n item.compositeResolved?.selections ??\n []) as Array<{\n component_id?: string;\n product_name?: string;\n component_name?: string;\n quantity: number;\n product_type?: string;\n }>\n ).map((sel) => {\n const name = sel.product_name ?? sel.component_name ?? \"\";\n const compId = sel.component_id ?? `${name}-${sel.quantity}`;\n return (\n <li\n key={compId}\n data-product-type={sel.product_type}\n className=\"flex items-baseline gap-1.5 m-0\"\n >\n <span className=\"font-medium text-foreground/80\">{sel.quantity}\\u00D7</span>\n <span className=\"truncate\">{name}</span>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Bottom row: line price + qty stepper */}\n <div className=\"mt-3 flex items-center justify-between gap-3\">\n <Price\n amount={lineTotal}\n currency={currency}\n className={cn(\"text-sm font-semibold tabular-nums\", classNames?.itemPrice)}\n />\n <div data-cimplify-cart-item-controls className={classNames?.quantity}>\n <QuantitySelector\n value={item.quantity}\n onChange={(qty) => onQuantityChange(item.id, qty)}\n min={0}\n />\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/**\n * CartSummary — renders cart line items + totals.\n *\n * NOT a drawer or modal — just the cart content. Templates wrap this in\n * their own drawer/modal shell with animations.\n */\nexport function CartSummary({\n cart: serverCart,\n onCheckout,\n onItemRemove,\n onQuantityChange,\n emptyMessage = \"Your cart is empty\",\n showTotals = true,\n showCheckoutButton,\n className,\n classNames,\n}: CartSummaryProps): React.ReactElement {\n const {\n items,\n itemCount,\n subtotal,\n tax,\n taxRate,\n taxInclusive,\n taxJurisdictionLabel,\n total,\n isEmpty,\n isOptimistic,\n pendingOpCount,\n currency,\n removeItem,\n updateQuantity,\n } = useCart();\n const displayCurrency = serverCart?.currency ?? currencyCode(currency);\n\n const deliveryFee = serverCart?.delivery_fee;\n const serviceCharge = serverCart?.service_charge;\n const totalDiscounts = serverCart?.total_discounts;\n\n const handleRemove = (itemId: string) => {\n if (onItemRemove) {\n onItemRemove(itemId);\n } else {\n void removeItem(itemId);\n }\n };\n\n const handleQuantityChange = (itemId: string, qty: number) => {\n if (onQuantityChange) {\n onQuantityChange(itemId, qty);\n } else {\n void updateQuantity(itemId, qty);\n }\n };\n\n return (\n <div\n data-cimplify-cart-summary\n data-optimistic={isOptimistic ? \"true\" : undefined}\n data-pending-ops={pendingOpCount > 0 ? pendingOpCount : undefined}\n className={cn(className, classNames?.root)}\n >\n {isEmpty ? (\n <div data-cimplify-cart-empty className={classNames?.empty}>\n <p>{emptyMessage}</p>\n </div>\n ) : (\n <>\n {/* Line items */}\n <div data-cimplify-cart-items>\n {items.map((item) => (\n <CartLineItemRow\n key={item.id}\n item={item}\n onRemove={handleRemove}\n onQuantityChange={handleQuantityChange}\n currency={displayCurrency}\n classNames={classNames}\n />\n ))}\n </div>\n\n {/* Totals */}\n {showTotals && (\n <div\n data-cimplify-cart-totals\n className={cn(\n \"mt-6 pt-4 border-t border-border space-y-2 text-sm\",\n classNames?.totals,\n )}\n >\n <div\n data-cimplify-cart-subtotal\n className={cn(\"flex items-baseline justify-between\", classNames?.subtotal)}\n >\n <span className=\"text-muted-foreground\">\n Subtotal ({itemCount} {itemCount === 1 ? \"item\" : \"items\"})\n </span>\n <Price amount={subtotal} currency={displayCurrency} className=\"tabular-nums\" />\n </div>\n\n {totalDiscounts != null && parsePrice(totalDiscounts) > 0 && (\n <div\n data-cimplify-cart-discount\n className={cn(\"flex items-baseline justify-between\", classNames?.discount)}\n >\n <span className=\"text-muted-foreground\">Discount</span>\n <Price\n amount={totalDiscounts}\n currency={displayCurrency}\n prefix=\"-\"\n className=\"tabular-nums text-primary\"\n />\n </div>\n )}\n\n {deliveryFee != null && (\n <div\n data-cimplify-cart-delivery-fee\n className={cn(\"flex items-baseline justify-between\", classNames?.deliveryFee)}\n >\n <span className=\"text-muted-foreground\">Delivery</span>\n {parsePrice(deliveryFee) > 0 ? (\n <Price\n amount={deliveryFee}\n currency={displayCurrency}\n className=\"tabular-nums\"\n />\n ) : (\n <span className=\"font-medium text-primary\">Free</span>\n )}\n </div>\n )}\n\n {serviceCharge != null && parsePrice(serviceCharge) > 0 && (\n <div\n data-cimplify-cart-service-charge\n className={cn(\"flex items-baseline justify-between\", classNames?.serviceCharge)}\n >\n <span className=\"text-muted-foreground\">Service charge</span>\n <Price\n amount={serviceCharge}\n currency={displayCurrency}\n className=\"tabular-nums\"\n />\n </div>\n )}\n\n <div\n data-cimplify-cart-tax\n className={cn(\"flex items-baseline justify-between\", classNames?.tax)}\n >\n <span className=\"flex items-baseline gap-1.5 text-muted-foreground\">\n {taxInclusive\n ? \"Tax, included in prices\"\n : taxJurisdictionLabel\n ? `Sales tax, ${taxJurisdictionLabel}`\n : \"Sales tax\"}\n {taxRate != null && taxRate > 0 && (\n <span className=\"rounded-full bg-muted px-1.5 py-0.5 text-[11px] font-medium\">\n {taxRate}%\n </span>\n )}\n </span>\n <Price amount={tax} currency={displayCurrency} className=\"tabular-nums\" />\n </div>\n\n <div\n data-cimplify-cart-total\n className={cn(\n \"flex items-baseline justify-between pt-3 mt-1 border-t border-border\",\n classNames?.total,\n )}\n >\n <span className=\"font-semibold\">Total</span>\n <Price\n amount={total}\n currency={displayCurrency}\n className=\"text-base font-bold tabular-nums\"\n />\n </div>\n </div>\n )}\n\n {(showCheckoutButton ?? Boolean(onCheckout)) && onCheckout && (\n <button\n type=\"button\"\n onClick={onCheckout}\n data-cimplify-cart-checkout\n className={cn(\n \"mt-4 w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all\",\n classNames?.checkout,\n )}\n >\n Proceed to Checkout\n </button>\n )}\n </>\n )}\n </div>\n );\n}\n"
14
14
  }
15
15
  ]
16
16
  }
@@ -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: 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"
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 = 25 * 1024 * 1024;\n\n/** Mirrors the server allowlist: images plus inert document types. */\nconst DOCUMENT_MIME_TYPES = new Set([\n \"application/pdf\",\n \"text/plain\",\n \"text/csv\",\n \"application/msword\",\n \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n \"application/vnd.ms-excel\",\n \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n]);\n\nconst ATTACHMENT_ACCEPT = [\"image/*\", ...DOCUMENT_MIME_TYPES].join(\",\");\n\nfunction isAllowedAttachment(file: File): boolean {\n return file.type.startsWith(\"image/\") || DOCUMENT_MIME_TYPES.has(file.type);\n}\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 attachFile = useCallback(\n async (file: File) => {\n if (!client) return;\n setAttachError(null);\n if (!isAllowedAttachment(file)) {\n setAttachError(\n new CimplifyError(\"invalid_attachment\", \"Only photos and documents can be attached.\"),\n );\n return;\n }\n if (file.size > MAX_ATTACHMENT_BYTES) {\n setAttachError(new CimplifyError(\"attachment_too_large\", \"Files must be under 25MB.\"));\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={attachFile}\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={ATTACHMENT_ACCEPT}\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
  }