@lindaui/blocks 0.4.2 → 0.5.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 (255) hide show
  1. package/README.md +56 -0
  2. package/dist/ai-cost-card-f403ypsg.js +99 -0
  3. package/dist/ai-performance-card-cggm09nc.js +191 -0
  4. package/dist/analytics/ai-cost-card.d.ts +3 -0
  5. package/dist/analytics/ai-cost-card.d.ts.map +1 -0
  6. package/dist/analytics/ai-performance-card.d.ts +3 -0
  7. package/dist/analytics/ai-performance-card.d.ts.map +1 -0
  8. package/dist/analytics/analytics-card.d.ts +3 -0
  9. package/dist/analytics/analytics-card.d.ts.map +1 -0
  10. package/dist/analytics/analytics-skeleton.d.ts +5 -0
  11. package/dist/analytics/analytics-skeleton.d.ts.map +1 -0
  12. package/dist/analytics/entity-health-card.d.ts +3 -0
  13. package/dist/analytics/entity-health-card.d.ts.map +1 -0
  14. package/dist/analytics/status-distribution-chart.d.ts +3 -0
  15. package/dist/analytics/status-distribution-chart.d.ts.map +1 -0
  16. package/dist/analytics/time-range-picker.d.ts +3 -0
  17. package/dist/analytics/time-range-picker.d.ts.map +1 -0
  18. package/dist/analytics/top-entities-table.d.ts +3 -0
  19. package/dist/analytics/top-entities-table.d.ts.map +1 -0
  20. package/dist/analytics/types.d.ts +104 -0
  21. package/dist/analytics/types.d.ts.map +1 -0
  22. package/dist/analytics-card-bcmijpt6.js +38 -0
  23. package/dist/analytics-skeleton-imve37du.js +164 -0
  24. package/dist/analytics.d.ts +11 -0
  25. package/dist/analytics.d.ts.map +1 -0
  26. package/dist/analytics.js +8 -0
  27. package/dist/app-shell/drawer-focus.d.ts +8 -0
  28. package/dist/app-shell/drawer-focus.d.ts.map +1 -0
  29. package/dist/app-shell/footer.d.ts +39 -0
  30. package/dist/app-shell/footer.d.ts.map +1 -0
  31. package/dist/app-shell/logout-dialog.d.ts +6 -0
  32. package/dist/app-shell/logout-dialog.d.ts.map +1 -0
  33. package/dist/app-shell/navigation.d.ts +15 -0
  34. package/dist/app-shell/navigation.d.ts.map +1 -0
  35. package/dist/app-shell/types.d.ts +55 -0
  36. package/dist/app-shell/types.d.ts.map +1 -0
  37. package/dist/app-shell.d.ts +7 -0
  38. package/dist/app-shell.d.ts.map +1 -0
  39. package/dist/app-shell.js +254 -0
  40. package/dist/channel-settings.d.ts +24 -0
  41. package/dist/channel-settings.d.ts.map +1 -0
  42. package/dist/channel-settings.js +60 -0
  43. package/dist/chat-avatar-jdmeyf2o.js +30 -0
  44. package/dist/chat-layout-fwx3x26g.js +25 -0
  45. package/dist/chat.d.ts +22 -0
  46. package/dist/chat.d.ts.map +1 -0
  47. package/dist/chat.js +66 -0
  48. package/dist/command-palette.d.ts +22 -0
  49. package/dist/command-palette.d.ts.map +1 -0
  50. package/dist/command-palette.js +141 -0
  51. package/dist/compat-chat/chat-avatar.d.ts +16 -0
  52. package/dist/compat-chat/chat-avatar.d.ts.map +1 -0
  53. package/dist/compat-chat/chat-header.d.ts +9 -0
  54. package/dist/compat-chat/chat-header.d.ts.map +1 -0
  55. package/dist/compat-chat/chat-layout.d.ts +10 -0
  56. package/dist/compat-chat/chat-layout.d.ts.map +1 -0
  57. package/dist/compat-chat/conversation-list-item.d.ts +10 -0
  58. package/dist/compat-chat/conversation-list-item.d.ts.map +1 -0
  59. package/dist/compat-chat/conversation-list.d.ts +24 -0
  60. package/dist/compat-chat/conversation-list.d.ts.map +1 -0
  61. package/dist/compat-chat/conversation-thread.d.ts +18 -0
  62. package/dist/compat-chat/conversation-thread.d.ts.map +1 -0
  63. package/dist/compat-chat/date-divider.d.ts +5 -0
  64. package/dist/compat-chat/date-divider.d.ts.map +1 -0
  65. package/dist/compat-chat/empty-chat-view.d.ts +6 -0
  66. package/dist/compat-chat/empty-chat-view.d.ts.map +1 -0
  67. package/dist/compat-chat/image-lightbox.d.ts +7 -0
  68. package/dist/compat-chat/image-lightbox.d.ts.map +1 -0
  69. package/dist/compat-chat/message-bubble.d.ts +6 -0
  70. package/dist/compat-chat/message-bubble.d.ts.map +1 -0
  71. package/dist/compat-chat/message-composer.d.ts +11 -0
  72. package/dist/compat-chat/message-composer.d.ts.map +1 -0
  73. package/dist/compat-chat/time-divider.d.ts +5 -0
  74. package/dist/compat-chat/time-divider.d.ts.map +1 -0
  75. package/dist/compat-chat/time-grouping.d.ts +5 -0
  76. package/dist/compat-chat/time-grouping.d.ts.map +1 -0
  77. package/dist/compat-chat/types.d.ts +35 -0
  78. package/dist/compat-chat/types.d.ts.map +1 -0
  79. package/dist/compat-chat/whatsapp-audio-player.d.ts +7 -0
  80. package/dist/compat-chat/whatsapp-audio-player.d.ts.map +1 -0
  81. package/dist/compat.d.ts +9 -0
  82. package/dist/compat.d.ts.map +1 -0
  83. package/dist/compat.js +4 -0
  84. package/dist/connected-panel-lhwy25yk.js +132 -0
  85. package/dist/connection-panel/connected-panel.d.ts +26 -0
  86. package/dist/connection-panel/connected-panel.d.ts.map +1 -0
  87. package/dist/connection-panel/disconnected-panel.d.ts +12 -0
  88. package/dist/connection-panel/disconnected-panel.d.ts.map +1 -0
  89. package/dist/connection-panel/instructions.d.ts +5 -0
  90. package/dist/connection-panel/instructions.d.ts.map +1 -0
  91. package/dist/connection-panel/qr-panel.d.ts +14 -0
  92. package/dist/connection-panel/qr-panel.d.ts.map +1 -0
  93. package/dist/connection-panel/use-qr-refresh.d.ts +18 -0
  94. package/dist/connection-panel/use-qr-refresh.d.ts.map +1 -0
  95. package/dist/connection-panel.d.ts +39 -0
  96. package/dist/connection-panel.d.ts.map +1 -0
  97. package/dist/connection-panel.js +328 -0
  98. package/dist/conversation-list-ekq2dzca.js +133 -0
  99. package/dist/conversation-list-item-fy6t5af2.js +55 -0
  100. package/dist/conversation-thread-c60431oa.js +205 -0
  101. package/dist/entity-card.d.ts +17 -0
  102. package/dist/entity-card.d.ts.map +1 -0
  103. package/dist/entity-card.js +69 -0
  104. package/dist/entity-health-card-ggnmrtrq.js +192 -0
  105. package/dist/entity-switcher.d.ts +33 -0
  106. package/dist/entity-switcher.d.ts.map +1 -0
  107. package/dist/entity-switcher.js +137 -0
  108. package/dist/filter-bar.d.ts +41 -0
  109. package/dist/filter-bar.d.ts.map +1 -0
  110. package/dist/filter-bar.js +155 -0
  111. package/dist/footer-f7r5e42b.js +192 -0
  112. package/dist/image-lightbox-ci3arb6z.js +62 -0
  113. package/dist/kanban/drop-utils.d.ts +18 -0
  114. package/dist/kanban/drop-utils.d.ts.map +1 -0
  115. package/dist/kanban/navigation-buttons.d.ts +9 -0
  116. package/dist/kanban/navigation-buttons.d.ts.map +1 -0
  117. package/dist/kanban/scroll-indicators.d.ts +6 -0
  118. package/dist/kanban/scroll-indicators.d.ts.map +1 -0
  119. package/dist/kanban/use-keyboard-navigation.d.ts +4 -0
  120. package/dist/kanban/use-keyboard-navigation.d.ts.map +1 -0
  121. package/dist/kanban-board.d.ts +31 -0
  122. package/dist/kanban-board.d.ts.map +1 -0
  123. package/dist/kanban-board.js +128 -0
  124. package/dist/kanban-column.d.ts +13 -0
  125. package/dist/kanban-column.d.ts.map +1 -0
  126. package/dist/kanban-column.js +58 -0
  127. package/dist/legacy-kpi-card.d.ts +16 -0
  128. package/dist/legacy-kpi-card.d.ts.map +1 -0
  129. package/dist/legacy-kpi-card.js +4 -0
  130. package/dist/logout-dialog-bbv59tbi.js +93 -0
  131. package/dist/message-bubble-odjvhroq.js +110 -0
  132. package/dist/message-composer-m2n6soo7.js +59 -0
  133. package/dist/navigation-buttons-e77mshh0.js +65 -0
  134. package/dist/navigation-hotcrqoq.js +115 -0
  135. package/dist/save-bar.d.ts +15 -0
  136. package/dist/save-bar.d.ts.map +1 -0
  137. package/dist/save-bar.js +38 -0
  138. package/dist/scroll-indicators-gvxapogd.js +39 -0
  139. package/dist/settings-layout.d.ts +20 -0
  140. package/dist/settings-layout.d.ts.map +1 -0
  141. package/dist/settings-layout.js +56 -0
  142. package/dist/source-selector.d.ts +18 -0
  143. package/dist/source-selector.d.ts.map +1 -0
  144. package/dist/source-selector.js +59 -0
  145. package/dist/status-distribution-chart-oucedzv7.js +87 -0
  146. package/dist/status-filters.d.ts +12 -0
  147. package/dist/status-filters.d.ts.map +1 -0
  148. package/dist/status-filters.js +31 -0
  149. package/dist/stepper.d.ts +15 -0
  150. package/dist/stepper.d.ts.map +1 -0
  151. package/dist/stepper.js +42 -0
  152. package/dist/time-range-picker-mu38hul4.js +123 -0
  153. package/dist/top-entities-table-o7xzpq96.js +78 -0
  154. package/dist/use-keyboard-navigation-he1xmriz.js +52 -0
  155. package/dist/whatsapp-audio-player-nooty6jb.js +277 -0
  156. package/llms.txt +87 -4
  157. package/package.json +191 -46
  158. package/src/analytics/ai-cost-card.tsx +31 -0
  159. package/src/analytics/ai-performance-card.tsx +48 -0
  160. package/src/analytics/analytics-card.tsx +14 -0
  161. package/src/analytics/analytics-skeleton.tsx +15 -0
  162. package/src/analytics/entity-health-card.tsx +50 -0
  163. package/src/analytics/status-distribution-chart.tsx +20 -0
  164. package/src/analytics/time-range-picker.tsx +43 -0
  165. package/src/analytics/top-entities-table.tsx +24 -0
  166. package/src/analytics/types.ts +43 -0
  167. package/src/analytics.ts +10 -0
  168. package/src/app-shell/drawer-focus.ts +42 -0
  169. package/src/app-shell/footer.tsx +160 -0
  170. package/src/app-shell/logout-dialog.tsx +68 -0
  171. package/src/app-shell/navigation.tsx +147 -0
  172. package/src/app-shell/types.ts +57 -0
  173. package/src/app-shell.tsx +166 -0
  174. package/src/area-chart.tsx +85 -0
  175. package/src/audio-player.tsx +74 -0
  176. package/src/auth-layout.tsx +132 -0
  177. package/src/auth-provider.tsx +29 -0
  178. package/src/bar-chart.tsx +78 -0
  179. package/src/channel-settings.tsx +76 -0
  180. package/src/chart-card.tsx +45 -0
  181. package/src/chat-avatar.tsx +57 -0
  182. package/src/chat-date-divider.tsx +18 -0
  183. package/src/chat-header.tsx +24 -0
  184. package/src/chat-layout.tsx +41 -0
  185. package/src/chat-time-divider.tsx +20 -0
  186. package/src/chat-types.ts +32 -0
  187. package/src/chat.ts +21 -0
  188. package/src/command-palette.tsx +110 -0
  189. package/src/compat-chat/chat-avatar.tsx +23 -0
  190. package/src/compat-chat/chat-header.tsx +12 -0
  191. package/src/compat-chat/chat-layout.tsx +14 -0
  192. package/src/compat-chat/conversation-list-item.tsx +27 -0
  193. package/src/compat-chat/conversation-list.tsx +49 -0
  194. package/src/compat-chat/conversation-thread.tsx +92 -0
  195. package/src/compat-chat/date-divider.tsx +7 -0
  196. package/src/compat-chat/empty-chat-view.tsx +9 -0
  197. package/src/compat-chat/image-lightbox.tsx +22 -0
  198. package/src/compat-chat/message-bubble.tsx +28 -0
  199. package/src/compat-chat/message-composer.tsx +26 -0
  200. package/src/compat-chat/time-divider.tsx +5 -0
  201. package/src/compat-chat/time-grouping.ts +19 -0
  202. package/src/compat-chat/types.ts +31 -0
  203. package/src/compat-chat/whatsapp-audio-player.tsx +100 -0
  204. package/src/compat.ts +8 -0
  205. package/src/confirm-dialog.tsx +85 -0
  206. package/src/connection-panel/connected-panel.tsx +106 -0
  207. package/src/connection-panel/disconnected-panel.tsx +48 -0
  208. package/src/connection-panel/instructions.tsx +9 -0
  209. package/src/connection-panel/qr-panel.tsx +62 -0
  210. package/src/connection-panel/use-qr-refresh.ts +52 -0
  211. package/src/connection-panel.tsx +141 -0
  212. package/src/conversation-list-item.tsx +53 -0
  213. package/src/conversation-list.tsx +67 -0
  214. package/src/conversation-thread.tsx +64 -0
  215. package/src/detail-panel.tsx +123 -0
  216. package/src/document-panel.tsx +109 -0
  217. package/src/document-reader.tsx +29 -0
  218. package/src/empty-chat-view.tsx +25 -0
  219. package/src/entity-card.tsx +74 -0
  220. package/src/entity-health-card.tsx +53 -0
  221. package/src/entity-switcher.tsx +126 -0
  222. package/src/fetching-indicator.tsx +16 -0
  223. package/src/field-array-form.tsx +166 -0
  224. package/src/filter-bar.tsx +203 -0
  225. package/src/image-viewer.tsx +129 -0
  226. package/src/kanban/drop-utils.ts +31 -0
  227. package/src/kanban/navigation-buttons.tsx +80 -0
  228. package/src/kanban/scroll-indicators.tsx +47 -0
  229. package/src/kanban/use-keyboard-navigation.ts +43 -0
  230. package/src/kanban-board.tsx +160 -0
  231. package/src/kanban-column.tsx +62 -0
  232. package/src/legacy-kpi-card.tsx +38 -0
  233. package/src/line-chart.tsx +58 -0
  234. package/src/list-item.tsx +63 -0
  235. package/src/list-pane.tsx +143 -0
  236. package/src/login-form.tsx +128 -0
  237. package/src/message-bubble.tsx +70 -0
  238. package/src/message-composer.tsx +52 -0
  239. package/src/pie-chart.tsx +70 -0
  240. package/src/radar-chart.tsx +55 -0
  241. package/src/radial-chart.tsx +103 -0
  242. package/src/recording-overlay.tsx +159 -0
  243. package/src/save-bar.tsx +35 -0
  244. package/src/settings-layout.tsx +79 -0
  245. package/src/source-selector.tsx +71 -0
  246. package/src/split-workspace.tsx +74 -0
  247. package/src/stat-card.tsx +166 -0
  248. package/src/stats-grid.tsx +23 -0
  249. package/src/status-filters.tsx +28 -0
  250. package/src/stepper.tsx +47 -0
  251. package/src/table-pane.tsx +194 -0
  252. package/src/time-range-picker.tsx +89 -0
  253. package/src/workspace-back-button.tsx +32 -0
  254. package/dist/chat-types.test.d.ts +0 -2
  255. package/dist/chat-types.test.d.ts.map +0 -1
@@ -0,0 +1,57 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+
4
+ export type ChatAvatarSize = "sm" | "md" | "lg";
5
+
6
+ export interface ChatAvatarProps {
7
+ src?: string;
8
+ name: string;
9
+ size?: ChatAvatarSize;
10
+ /** Se dispara cuando `src` falla en cargar (además del fallback automático a iniciales). */
11
+ onError?: () => void;
12
+ }
13
+
14
+ const sizeClasses: Record<ChatAvatarSize, string> = {
15
+ sm: "size-8 text-xs",
16
+ md: "size-10 text-sm",
17
+ lg: "size-12 text-base",
18
+ };
19
+
20
+ function initials(name: string): string {
21
+ const parts = name.trim().split(/\s+/).filter(Boolean);
22
+ if (parts.length === 0) return "?";
23
+ if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
24
+ return (parts[0]![0] + parts[parts.length - 1]![0]).toUpperCase();
25
+ }
26
+
27
+ /**
28
+ * Avatar de chat con fallback a iniciales. Bespoke (no compone
29
+ * `@lindaui/ui/avatar`): ese wrapper solo expone el Root de HeroUI, sin
30
+ * `Avatar.Image`/`Avatar.Fallback` — no alcanza para un fallback-on-error real.
31
+ */
32
+ export function ChatAvatar({ src, name, size = "md", onError }: ChatAvatarProps) {
33
+ const [broken, setBroken] = useState(false);
34
+ const showImage = src != null && !broken;
35
+
36
+ return (
37
+ <div
38
+ role="img"
39
+ aria-label={name}
40
+ className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-secondary font-medium text-secondary-foreground ${sizeClasses[size]}`}
41
+ >
42
+ {showImage ? (
43
+ <img
44
+ src={src}
45
+ alt=""
46
+ className="h-full w-full object-cover"
47
+ onError={() => {
48
+ setBroken(true);
49
+ onError?.();
50
+ }}
51
+ />
52
+ ) : (
53
+ <span aria-hidden="true">{initials(name)}</span>
54
+ )}
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { formatRelativeTime } from "@lindaui/ui/date";
3
+
4
+ export interface ChatDateDividerProps {
5
+ date: Date | number | string;
6
+ }
7
+
8
+ /** Divider centrado de día ("Hoy"/"Ayer"/fecha corta) para timelines de chat. */
9
+ export function ChatDateDivider({ date }: ChatDateDividerProps) {
10
+ const label = formatRelativeTime(date);
11
+ return (
12
+ <div className="my-3 flex items-center justify-center" role="separator" aria-label={label}>
13
+ <span className="rounded-full bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground">
14
+ {label}
15
+ </span>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import type { ReactNode } from "react";
3
+
4
+ export interface ChatHeaderProps {
5
+ avatar?: ReactNode;
6
+ title: ReactNode;
7
+ subtitle?: ReactNode;
8
+ actions?: ReactNode;
9
+ }
10
+
11
+ export function ChatHeader({ avatar, title, subtitle, actions }: ChatHeaderProps) {
12
+ return (
13
+ <div className="flex items-center gap-3 border-b border-border bg-card px-4 py-3">
14
+ {avatar}
15
+ <div className="min-w-0 flex-1">
16
+ <div className="truncate text-sm font-semibold">{title}</div>
17
+ {subtitle != null && (
18
+ <div className="truncate text-xs text-muted-foreground">{subtitle}</div>
19
+ )}
20
+ </div>
21
+ {actions != null && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type ChatLayoutPanel = "sidebar" | "main" | "detail";
5
+
6
+ export interface ChatLayoutProps {
7
+ sidebar: ReactNode;
8
+ main: ReactNode;
9
+ detail?: ReactNode;
10
+ /** Panel visible en mobile (un solo panel a la vez). Default "sidebar". */
11
+ activePanel?: ChatLayoutPanel;
12
+ }
13
+
14
+ /** Shell responsive de 3 columnas (sidebar/main/detail); mobile-first, un panel a la vez. */
15
+ export function ChatLayout({ sidebar, main, detail, activePanel = "sidebar" }: ChatLayoutProps) {
16
+ return (
17
+ <div className="flex h-full min-h-0 w-full overflow-hidden">
18
+ <div
19
+ className={`w-full shrink-0 border-r border-border sm:block sm:w-80 ${
20
+ activePanel === "sidebar" ? "block" : "hidden sm:block"
21
+ }`}
22
+ >
23
+ {sidebar}
24
+ </div>
25
+ <div
26
+ className={`min-w-0 flex-1 sm:block ${activePanel === "main" ? "block" : "hidden sm:block"}`}
27
+ >
28
+ {main}
29
+ </div>
30
+ {detail != null && (
31
+ <div
32
+ className={`w-full shrink-0 border-l border-border lg:block lg:w-96 ${
33
+ activePanel === "detail" ? "block" : "hidden lg:block"
34
+ }`}
35
+ >
36
+ {detail}
37
+ </div>
38
+ )}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ export interface ChatTimeDividerProps {
4
+ label: string;
5
+ }
6
+
7
+ /** Header sticky de grupo horario (variante más chica que {@link ChatDateDivider}). */
8
+ export function ChatTimeDivider({ label }: ChatTimeDividerProps) {
9
+ return (
10
+ <div
11
+ className="sticky top-0 z-10 flex justify-center bg-card/80 py-1.5 backdrop-blur"
12
+ role="separator"
13
+ aria-label={label}
14
+ >
15
+ <span className="rounded-full bg-secondary px-2.5 py-0.5 text-[11px] font-medium text-secondary-foreground">
16
+ {label}
17
+ </span>
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Tipos compartidos por la familia `chat-*`. Agnósticos de dominio — el
3
+ * consumidor mapea su shape real (mensajes de WhatsApp, tickets, etc.) a
4
+ * estos antes de pasarlos a los blocks.
5
+ */
6
+
7
+ export type MessageSide = "incoming" | "outgoing";
8
+
9
+ export type MessageStatus = "sending" | "sent" | "delivered" | "read" | "failed";
10
+
11
+ export type GenericMessageMedia =
12
+ | { kind: "image"; url: string; alt?: string }
13
+ | { kind: "audio"; url: string; durationSeconds?: number }
14
+ | { kind: "pdf"; url: string; name?: string };
15
+
16
+ export interface GenericMessage {
17
+ id: string;
18
+ side: MessageSide;
19
+ text?: string;
20
+ media?: GenericMessageMedia;
21
+ sentAt: Date | number | string;
22
+ status?: MessageStatus;
23
+ }
24
+
25
+ export interface ConversationSummary {
26
+ id: string;
27
+ title: string;
28
+ subtitle?: string;
29
+ avatarUrl?: string;
30
+ lastMessageAt?: Date | number | string;
31
+ unreadCount?: number;
32
+ }
package/src/chat.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Generic migration API. The existing individual chat entry points are unchanged. */
2
+ export { ChatAvatar } from "./compat-chat/chat-avatar";
3
+ export type { ChatAvatarProps, ChatAvatarClassNames } from "./compat-chat/chat-avatar";
4
+ export { ChatHeader } from "./compat-chat/chat-header";
5
+ export type { ChatHeaderProps } from "./compat-chat/chat-header";
6
+ export { ChatLayout } from "./compat-chat/chat-layout";
7
+ export type { ChatLayoutProps } from "./compat-chat/chat-layout";
8
+ export { ConversationList } from "./compat-chat/conversation-list";
9
+ export type { ConversationListProps, ConversationListLabels } from "./compat-chat/conversation-list";
10
+ export { ConversationListItem } from "./compat-chat/conversation-list-item";
11
+ export type { ConversationListItemProps } from "./compat-chat/conversation-list-item";
12
+ export { ConversationThread } from "./compat-chat/conversation-thread";
13
+ export type { ConversationThreadProps } from "./compat-chat/conversation-thread";
14
+ export { EmptyChatView } from "./compat-chat/empty-chat-view";
15
+ export type { EmptyChatViewProps } from "./compat-chat/empty-chat-view";
16
+ export { MessageBubble } from "./compat-chat/message-bubble";
17
+ export type { MessageBubbleProps } from "./compat-chat/message-bubble";
18
+ export { MessageComposer } from "./compat-chat/message-composer";
19
+ export type { MessageComposerProps } from "./compat-chat/message-composer";
20
+ export type { ConversationSummary, ConversationThreadLabels, GenericMessage, GenericMessageMedia, MessageMediaKind, MessageSide } from "./compat-chat/types";
21
+ export type { TimeGroup } from "./compat-chat/time-grouping";
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import {
4
+ Avatar, Description, Header, InputGroup, Kbd, Label, ListBox,
5
+ Modal, ScrollShadow, Separator, TextField,
6
+ } from "@lindaui/ui/compound";
7
+ import { Search } from "lucide-react";
8
+ import { useCallback, useMemo, useState } from "react";
9
+ import type { ReactNode } from "react";
10
+
11
+ export interface CommandPaletteItem {
12
+ description?: string;
13
+ identifier: string;
14
+ name: string;
15
+ }
16
+
17
+ export interface CommandPaletteProps<T extends CommandPaletteItem = CommandPaletteItem> {
18
+ isOpen: boolean;
19
+ items: T[];
20
+ onClose: () => void;
21
+ onSelect: (item: T) => void;
22
+ placeholder?: string;
23
+ recentIds?: string[];
24
+ title?: string;
25
+ recentLabel?: string;
26
+ allItemsLabel?: string;
27
+ renderEmptyState?: (query: string) => ReactNode;
28
+ renderAvatar?: (item: T) => ReactNode;
29
+ }
30
+
31
+ /** Search names and descriptions, keeping recent items first in their original collection order. */
32
+ export function CommandPalette<T extends CommandPaletteItem>({
33
+ isOpen, onClose, items, onSelect, placeholder = "Search…", title = "Search",
34
+ recentIds = [], recentLabel = "Recent", allItemsLabel = "All items",
35
+ renderEmptyState, renderAvatar,
36
+ }: CommandPaletteProps<T>) {
37
+ const [query, setQuery] = useState("");
38
+ const filtered = useMemo(() => {
39
+ if (!query.trim()) return items;
40
+ const search = query.toLowerCase();
41
+ return items.filter((item) => item.name.toLowerCase().includes(search)
42
+ || item.description?.toLowerCase().includes(search));
43
+ }, [items, query]);
44
+ const recents = filtered.filter((item) => recentIds.includes(item.identifier));
45
+ const rest = filtered.filter((item) => !recentIds.includes(item.identifier));
46
+
47
+ const handleClose = useCallback(() => {
48
+ onClose();
49
+ setQuery("");
50
+ }, [onClose]);
51
+ const handleSelect = useCallback((key: string | number) => {
52
+ const item = items.find((candidate) => candidate.identifier === String(key));
53
+ if (item) {
54
+ onSelect(item);
55
+ setQuery("");
56
+ }
57
+ }, [items, onSelect]);
58
+ const renderItem = (item: T) => (
59
+ <ListBox.Item id={item.identifier} key={item.identifier} textValue={item.name}>
60
+ <Avatar size="sm">
61
+ {renderAvatar ? renderAvatar(item) : (
62
+ <Avatar.Fallback>{item.name.charAt(0).toUpperCase()}</Avatar.Fallback>
63
+ )}
64
+ </Avatar>
65
+ <Label>{item.name}</Label>
66
+ {item.description ? <Description>{item.description}</Description> : null}
67
+ </ListBox.Item>
68
+ );
69
+
70
+ return (
71
+ <Modal.Backdrop isOpen={isOpen} onOpenChange={(open) => { if (!open) handleClose(); }} variant="blur">
72
+ <Modal.Container placement="top" size="lg">
73
+ <Modal.Dialog aria-label={title}>
74
+ <Modal.Body className="gap-0 p-0">
75
+ <TextField aria-label={title} value={query}>
76
+ <InputGroup className="rounded-none rounded-t-xl border-b shadow-none">
77
+ <InputGroup.Prefix><Search className="text-muted" size={18} /></InputGroup.Prefix>
78
+ <InputGroup.Input autoFocus onChange={(event) => setQuery(event.target.value)} placeholder={placeholder} value={query} />
79
+ <InputGroup.Suffix>
80
+ <Kbd><Kbd.Abbr keyValue="escape" /><Kbd.Content>ESC</Kbd.Content></Kbd>
81
+ </InputGroup.Suffix>
82
+ </InputGroup>
83
+ </TextField>
84
+ <ScrollShadow className="max-h-[360px]">
85
+ <ListBox
86
+ aria-label={title}
87
+ onAction={handleSelect}
88
+ renderEmptyState={() => renderEmptyState ? renderEmptyState(query) : (
89
+ <p className="py-6 text-center text-muted text-sm">No results for &ldquo;{query}&rdquo;</p>
90
+ )}
91
+ >
92
+ {recents.length > 0 ? (
93
+ <ListBox.Section>
94
+ <Header>{recentLabel}</Header>
95
+ {recents.map(renderItem)}
96
+ <Separator />
97
+ </ListBox.Section>
98
+ ) : null}
99
+ <ListBox.Section>
100
+ {recents.length > 0 ? <Header>{allItemsLabel}</Header> : null}
101
+ {rest.map(renderItem)}
102
+ </ListBox.Section>
103
+ </ListBox>
104
+ </ScrollShadow>
105
+ </Modal.Body>
106
+ </Modal.Dialog>
107
+ </Modal.Container>
108
+ </Modal.Backdrop>
109
+ );
110
+ }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { Avatar } from "@lindaui/ui/compound";
4
+
5
+ export interface ChatAvatarClassNames { base?: string; fallback?: string; img?: string; }
6
+ export interface ChatAvatarProps {
7
+ className?: string;
8
+ classNames?: ChatAvatarClassNames;
9
+ name: string;
10
+ onError?: () => void;
11
+ size?: "lg" | "md" | "sm";
12
+ src?: string | null;
13
+ }
14
+ function getInitials(name: string): string {
15
+ return name.split(" ").map((part) => part[0]).filter(Boolean).join("").toUpperCase().slice(0, 2);
16
+ }
17
+ /** Image refresh stays in the consumer's onError callback. */
18
+ export function ChatAvatar({ name, src, size, className, classNames, onError }: ChatAvatarProps) {
19
+ return <Avatar className={[className, classNames?.base].filter(Boolean).join(" ")} size={size}>
20
+ {src ? <Avatar.Image alt={name} className={classNames?.img} onError={onError} src={src} /> : null}
21
+ <Avatar.Fallback className={classNames?.fallback}>{getInitials(name)}</Avatar.Fallback>
22
+ </Avatar>;
23
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface ChatHeaderProps { actions?: ReactNode; avatar?: ReactNode; subtitle?: ReactNode; title: ReactNode; }
4
+ export function ChatHeader({ title, subtitle, avatar, actions }: ChatHeaderProps) {
5
+ return <div className="flex items-center justify-between gap-3 border-border border-b px-4 py-3">
6
+ <div className="flex min-w-0 flex-1 items-center gap-3">
7
+ {avatar}
8
+ <div className="min-w-0 flex-1"><h3 className="truncate font-medium text-foreground text-sm">{title}</h3>{subtitle && <p className="truncate text-muted text-xs">{subtitle}</p>}</div>
9
+ </div>
10
+ {actions}
11
+ </div>;
12
+ }
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { cn } from "@lindaui/ui/cn";
5
+
6
+ export interface ChatLayoutProps { detail?: ReactNode; main: ReactNode; selectedChatId?: string; sidebar: ReactNode; }
7
+ /** Mobile selection switching, 350px tablet sidebar, 320px desktop detail. */
8
+ export function ChatLayout({ sidebar, main, detail, selectedChatId }: ChatLayoutProps) {
9
+ return <div className="flex h-full w-full overflow-hidden bg-background">
10
+ <div className={cn("border-border border-r", "md:flex md:w-[350px] md:flex-col", selectedChatId ? "hidden" : "flex w-full flex-col")}>{sidebar}</div>
11
+ <div className={cn("flex-1 flex-col overflow-hidden", "md:flex", selectedChatId ? "flex" : "hidden")}>{main}</div>
12
+ {detail && <div className="hidden w-[320px] flex-col border-border border-l lg:flex">{detail}</div>}
13
+ </div>;
14
+ }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { memo, type ReactNode } from "react";
4
+ import { cn, formatRelativeTime } from "@lindaui/ui/compat";
5
+ import type { ConversationSummary } from "./types";
6
+
7
+ export interface ConversationListItemProps { avatar?: ReactNode; emptyPreview?: string; item: ConversationSummary; onClick: () => void; }
8
+ export const ConversationListItem = memo(({ item, onClick, avatar, emptyPreview }: ConversationListItemProps) => (
9
+ <button className={cn("w-full cursor-pointer px-4 py-3 transition-colors", "md:hover:bg-surface-secondary", item.isSelected && "bg-blue-50 dark:bg-blue-950/20")} onClick={onClick} type="button">
10
+ <div className="flex items-start gap-3">
11
+ {avatar}
12
+ <div className="min-w-0 flex-1 text-left">
13
+ <div className="mb-0.5 flex items-center justify-between gap-2">
14
+ <h4 className="truncate font-semibold text-foreground">{item.title}</h4>
15
+ {item.timestamp && <span className="flex-shrink-0 text-muted text-xs">{formatRelativeTime(item.timestamp)}</span>}
16
+ </div>
17
+ <div className="flex items-center justify-between gap-2">
18
+ <p className="truncate text-muted text-sm">{item.preview ?? emptyPreview}</p>
19
+ <div className="flex flex-shrink-0 items-center gap-1.5">
20
+ {item.badge}
21
+ {item.unreadCount ? <span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 font-semibold text-accent-foreground text-xs">{item.unreadCount}</span> : null}
22
+ </div>
23
+ </div>
24
+ </div>
25
+ </div>
26
+ </button>
27
+ ));
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { InputGroup, ScrollShadow, Spinner } from "@lindaui/ui/compound";
4
+ import { Search, X } from "lucide-react";
5
+ import { type ReactNode, useCallback } from "react";
6
+ import { TimeDivider } from "./time-divider";
7
+ import { groupByTime, TIME_GROUP_ORDER, type TimeGroup } from "./time-grouping";
8
+ import { ConversationListItem } from "./conversation-list-item";
9
+ import type { ConversationSummary } from "./types";
10
+
11
+ export interface ConversationListLabels { emptyPreview?: string; loading: string; periods: Record<TimeGroup, string>; }
12
+ export interface ConversationListProps {
13
+ emptyMessage?: ReactNode;
14
+ filters?: ReactNode;
15
+ isLoading?: boolean;
16
+ items: ConversationSummary[];
17
+ labels: ConversationListLabels;
18
+ onItemSelect: (id: string) => void;
19
+ onSearchChange: (value: string) => void;
20
+ renderAvatar?: (item: ConversationSummary) => ReactNode;
21
+ searchPlaceholder?: string;
22
+ searchValue: string;
23
+ /** Optional virtualized list. The consumer owns row placement and group headers. */
24
+ renderItems?: (items: ConversationSummary[], renderItem: (item: ConversationSummary) => ReactNode) => ReactNode;
25
+ }
26
+ export function ConversationList({ items, searchValue, onSearchChange, searchPlaceholder, filters, labels, isLoading = false, emptyMessage, onItemSelect, renderAvatar, renderItems }: ConversationListProps) {
27
+ const grouped = groupByTime(items, (item) => item.timestamp ?? new Date());
28
+ const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value), [onSearchChange]);
29
+ const handleClearSearch = useCallback(() => onSearchChange(""), [onSearchChange]);
30
+ const handleItemSelect = useCallback((id: string) => () => onItemSelect(id), [onItemSelect]);
31
+ const renderItem = (item: ConversationSummary) => <ConversationListItem avatar={renderAvatar?.(item)} emptyPreview={labels.emptyPreview} item={item} key={item.id} onClick={handleItemSelect(item.id)} />;
32
+ return <div className="flex h-full flex-col bg-background">
33
+ <div className="space-y-3 border-border border-b p-4">
34
+ <InputGroup className="w-full rounded-lg">
35
+ <InputGroup.Prefix><Search className="h-4 w-4 text-muted" /></InputGroup.Prefix>
36
+ <InputGroup.Input className="text-sm" onChange={handleSearchChange} placeholder={searchPlaceholder} value={searchValue} />
37
+ {searchValue && <InputGroup.Suffix><button className="text-muted hover:text-muted" onClick={handleClearSearch} type="button"><X className="h-4 w-4" /></button></InputGroup.Suffix>}
38
+ </InputGroup>
39
+ {filters}
40
+ </div>
41
+ <ScrollShadow className="flex-1 overflow-y-auto">
42
+ {isLoading && <div className="flex items-center justify-center gap-2 py-8"><Spinner size="sm" /><span className="text-muted text-sm">{labels.loading}</span></div>}
43
+ {!isLoading && items.length === 0 && <div className="flex flex-col items-center justify-center px-4 py-12 text-center"><p className="text-muted text-sm">{emptyMessage}</p></div>}
44
+ {!isLoading && items.length > 0 && (renderItems ? renderItems(items, renderItem) : <div>
45
+ {TIME_GROUP_ORDER.map((timeGroup) => grouped[timeGroup].length === 0 ? null : <div key={timeGroup}><TimeDivider label={labels.periods[timeGroup]} />{grouped[timeGroup].map(renderItem)}</div>)}
46
+ </div>)}
47
+ </ScrollShadow>
48
+ </div>;
49
+ }
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import { ScrollShadow, Spinner } from "@lindaui/ui/compound";
4
+ import { cn } from "@lindaui/ui/cn";
5
+ import dayjs from "dayjs";
6
+ import { AlertCircle } from "lucide-react";
7
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
8
+ import { DateDivider } from "./date-divider";
9
+ import { MessageBubble } from "./message-bubble";
10
+ import type { ConversationThreadLabels, GenericMessage } from "./types";
11
+
12
+ export interface ConversationThreadProps {
13
+ /** undefined uses the original light/dark pattern; null uses a flat background. */
14
+ backgroundImage?: { dark: string; light: string } | null;
15
+ isError?: boolean;
16
+ isLoading?: boolean;
17
+ labels: ConversationThreadLabels;
18
+ messages: GenericMessage[];
19
+ showPollingFallback?: boolean;
20
+ /** Optional virtualizer. The consumer owns row placement and date dividers. */
21
+ renderMessages?: (messages: GenericMessage[], renderMessage: (message: GenericMessage) => ReactNode) => ReactNode;
22
+ }
23
+ const DEFAULT_BACKGROUND = { dark: "/images/chat-bg-dark.png", light: "/images/chat-bg-light.png" } as const;
24
+ export function ConversationThread({ messages, isLoading = false, isError = false, labels, backgroundImage, showPollingFallback = false, renderMessages }: ConversationThreadProps) {
25
+ const scrollRef = useRef<HTMLDivElement>(null);
26
+ const messageListRef = useRef<HTMLDivElement>(null);
27
+ const groupedMessages = useMemo(() => {
28
+ const groups: { date: Date | string; messages: GenericMessage[] }[] = [];
29
+ let currentDay = "";
30
+ for (const msg of messages) {
31
+ const day = dayjs(msg.timestamp).format("YYYY-MM-DD");
32
+ if (day === currentDay) groups.at(-1)?.messages.push(msg);
33
+ else { currentDay = day; groups.push({ date: msg.timestamp, messages: [msg] }); }
34
+ }
35
+ return groups;
36
+ }, [messages]);
37
+ const hasScrolledOnLoad = useRef(false);
38
+ const firstId = messages.at(0)?.id ?? "";
39
+ const prevFirstIdRef = useRef(firstId);
40
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
41
+ const el = scrollRef.current;
42
+ if (!el) return;
43
+ if (behavior === "auto") { el.scrollTop = el.scrollHeight; return; }
44
+ el.scrollTo({ behavior, top: el.scrollHeight });
45
+ }, []);
46
+ useLayoutEffect(() => {
47
+ if (prevFirstIdRef.current !== firstId) { prevFirstIdRef.current = firstId; hasScrolledOnLoad.current = false; }
48
+ if (isLoading || messages.length === 0 || hasScrolledOnLoad.current) return;
49
+ const el = scrollRef.current;
50
+ const listEl = messageListRef.current;
51
+ if (!el || !listEl) return;
52
+ let settleTimeout: ReturnType<typeof setTimeout> | null = null;
53
+ let settled = false;
54
+ const scrollNow = () => { el.scrollTop = el.scrollHeight; };
55
+ const scheduleSettle = () => {
56
+ if (settleTimeout) clearTimeout(settleTimeout);
57
+ settleTimeout = setTimeout(() => {
58
+ settled = true;
59
+ hasScrolledOnLoad.current = true;
60
+ resizeObserver.disconnect();
61
+ mutationObserver.disconnect();
62
+ }, 250);
63
+ };
64
+ scrollNow();
65
+ scheduleSettle();
66
+ const resizeObserver = new ResizeObserver(() => { if (!settled) { scrollNow(); scheduleSettle(); } });
67
+ const mutationObserver = new MutationObserver(() => { if (!settled) { scrollNow(); scheduleSettle(); } });
68
+ resizeObserver.observe(listEl);
69
+ mutationObserver.observe(listEl, { childList: true, subtree: true });
70
+ return () => { if (settleTimeout) clearTimeout(settleTimeout); resizeObserver.disconnect(); mutationObserver.disconnect(); };
71
+ }, [isLoading, messages.length, firstId]);
72
+ const messageCount = messages.length;
73
+ const prevMessageCount = useRef(messageCount);
74
+ useEffect(() => {
75
+ if (messageCount > prevMessageCount.current) requestAnimationFrame(() => scrollToBottom("smooth"));
76
+ prevMessageCount.current = messageCount;
77
+ }, [messageCount, scrollToBottom]);
78
+ const bg = backgroundImage === undefined ? DEFAULT_BACKGROUND : backgroundImage;
79
+ const renderMessage = (message: GenericMessage) => <MessageBubble key={message.id} message={message} />;
80
+ return <div className={cn("relative flex h-full flex-col", bg ? "bg-[#efeae2] dark:bg-[#0b141a]" : "bg-background")}>
81
+ {bg && <><div className="pointer-events-none absolute inset-0 bg-contain opacity-15 dark:hidden" style={{ backgroundImage: `url('${bg.light}')` }} /><div className="pointer-events-none absolute inset-0 hidden bg-contain opacity-15 dark:block" style={{ backgroundImage: `url('${bg.dark}')` }} /></>}
82
+ {showPollingFallback && labels.pollingFallback && <div className="flex items-center justify-center py-1.5 text-muted text-xs">{labels.pollingFallback}</div>}
83
+ <ScrollShadow className="relative flex-1 overflow-y-auto px-4 pt-4 pb-20" ref={scrollRef}>
84
+ {isLoading && <div className="flex items-center justify-center gap-2 py-8"><Spinner size="sm" /><span className="text-muted text-sm">{labels.loading}</span></div>}
85
+ {isError && <div className="flex items-center justify-center gap-2 py-8 text-danger"><AlertCircle className="h-5 w-5" /><p className="text-sm">{labels.errorLoading}</p></div>}
86
+ {!isLoading && messages.length === 0 && <div className="flex items-center justify-center py-12 text-center"><p className="text-muted text-sm">{labels.noMessages}</p></div>}
87
+ {!isLoading && messages.length > 0 && <div className="space-y-3" ref={messageListRef}>
88
+ {renderMessages ? renderMessages(messages, renderMessage) : groupedMessages.map((group) => <div className="space-y-3" key={String(group.date)}><DateDivider date={group.date} />{group.messages.map(renderMessage)}</div>)}
89
+ </div>}
90
+ </ScrollShadow>
91
+ </div>;
92
+ }
@@ -0,0 +1,7 @@
1
+ import dayjs from "dayjs";
2
+ import { isToday, isYesterday } from "@lindaui/ui/compat";
3
+
4
+ export interface DateDividerProps { date: string | Date; }
5
+ export function DateDivider({ date }: DateDividerProps) {
6
+ return <div className="flex justify-center py-2"><span className="rounded-lg bg-surface-secondary px-3 py-1 text-muted text-xs shadow-sm dark:bg-surface-secondary/50">{isToday(date) ? "Hoy" : isYesterday(date) ? "Ayer" : dayjs(date).format("DD/MM/YYYY")}</span></div>;
7
+ }
@@ -0,0 +1,9 @@
1
+ import { MessageCircle } from "lucide-react";
2
+
3
+ export interface EmptyChatViewProps { description: string; title: string; }
4
+ export function EmptyChatView({ title, description }: EmptyChatViewProps) {
5
+ return <div className="flex h-full flex-col items-center justify-center gap-4 px-6 py-8 text-center">
6
+ <div className="rounded-full bg-surface-secondary p-4"><MessageCircle className="h-8 w-8 text-muted" /></div>
7
+ <div className="space-y-2"><h3 className="font-semibold text-foreground text-lg">{title}</h3><p className="text-muted text-sm">{description}</p></div>
8
+ </div>;
9
+ }
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ import { Modal } from "@lindaui/ui/compound";
4
+ import { useCallback } from "react";
5
+ import { useDisclosure } from "@lindaui/ui/use-disclosure";
6
+
7
+ export interface ImageLightboxProps { alt?: string; className?: string; src: string; }
8
+ export function ImageLightbox({ src, alt = "Media", className }: ImageLightboxProps) {
9
+ const { isOpen, open: onOpen, close: onClose } = useDisclosure();
10
+ const handleOpenChange = useCallback((open: boolean) => { if (!open) onClose(); }, [onClose]);
11
+ return <>
12
+ <button aria-label={`Ver imagen: ${alt}`} className={`cursor-pointer rounded-lg p-0 transition-opacity hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-accent ${className ?? ""}`} onClick={onOpen} type="button">
13
+ <img alt={alt} className="h-44 w-full max-w-[220px] rounded-lg object-cover" height={600} loading="lazy" src={src} width={800} />
14
+ </button>
15
+ <Modal.Backdrop className="bg-black/80" isOpen={isOpen} onOpenChange={handleOpenChange} variant="blur">
16
+ <Modal.Container className="overflow-hidden" size="full"><Modal.Dialog className="m-0 rounded-none shadow-none">
17
+ <Modal.CloseTrigger className="top-4 right-4 z-10 rounded-full bg-black/50 text-white hover:bg-black/70" />
18
+ <Modal.Body className="flex h-full items-center justify-center overflow-hidden p-4"><img alt={alt} className="max-h-full max-w-full rounded-lg object-contain" height={1080} src={src} width={1920} /></Modal.Body>
19
+ </Modal.Dialog></Modal.Container>
20
+ </Modal.Backdrop>
21
+ </>;
22
+ }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import dayjs from "dayjs";
4
+ import { motion } from "framer-motion";
5
+ import { cn } from "@lindaui/ui/cn";
6
+ import { ImageLightbox } from "./image-lightbox";
7
+ import { WhatsAppAudioPlayer } from "./whatsapp-audio-player";
8
+ import type { GenericMessage } from "./types";
9
+
10
+ export interface MessageBubbleProps { message: GenericMessage; }
11
+ function formatTime(date: Date | string): string { return dayjs(date).format("HH:mm"); }
12
+ export function MessageBubble({ message }: MessageBubbleProps) {
13
+ const isBot = message.side === "right";
14
+ const hasAudio = message.media?.kind === "audio";
15
+ if (message.side === "center") return <motion.div animate={{ opacity: 1 }} className="my-2 flex justify-center" initial={{ opacity: 0 }}><div className="rounded-full bg-surface-secondary px-3 py-1 text-muted text-xs">{message.text}</div></motion.div>;
16
+ return <motion.div animate={{ opacity: 1, y: 0 }} className={cn("flex w-full", isBot ? "justify-end" : "justify-start")} initial={{ opacity: 0, y: 10 }}>
17
+ <div className={cn("relative p-3 text-sm shadow-sm", hasAudio ? "w-[90%] max-w-[330px]" : "max-w-[85%] sm:max-w-[70%]", "rounded-2xl transition-all duration-200", isBot ? "rounded-tr-none bg-[#d9fdd3] text-[#111b21] dark:bg-[#005c4b] dark:text-white" : "rounded-tl-none bg-white text-[#111b21] dark:bg-[#202c33] dark:text-white")}>
18
+ <div className={cn("absolute top-0 h-0 w-0 border-solid", isBot ? "-right-[12px] border-t-[#d9fdd3] border-t-[10px] border-r-[12px] border-r-transparent dark:border-t-[#005c4b]" : "-left-[12px] border-t-[10px] border-t-white border-l-[12px] border-l-transparent dark:border-t-[#202c33]")} />
19
+ {message.media?.kind === "image" && <div className="mb-2 overflow-hidden rounded-lg"><ImageLightbox alt="Media compartida" src={message.media.url} /></div>}
20
+ {message.media?.kind === "audio" && <div className="mb-2"><WhatsAppAudioPlayer isBot={isBot} src={message.media.url} /></div>}
21
+ {message.media?.kind === "pdf" && <div className="mb-2"><a className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-surface-secondary dark:border-border dark:bg-default dark:text-muted dark:hover:bg-default" download href={message.media.url} rel="noopener noreferrer" target="_blank"><span>PDF</span><span className="max-w-[180px] truncate">{message.media.label ?? "Documento adjunto"}</span></a></div>}
22
+ <div className="relative">
23
+ <p className="whitespace-pre-wrap break-words">{message.text}<span className="invisible text-[10px]">{" "}{formatTime(message.timestamp)}{message.authorTag ? ` ${message.authorTag}` : ""}</span></p>
24
+ <span className="absolute right-0 bottom-0 flex items-center gap-1 text-[#52616b] text-[10px] dark:text-[#d1dedb]">{formatTime(message.timestamp)}{message.authorTag && <span>{message.authorTag}</span>}</span>
25
+ </div>
26
+ </div>
27
+ </motion.div>;
28
+ }