@adatechnology/conversations-ui 0.0.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 (65) hide show
  1. package/dist/channel/index.d.ts +2 -0
  2. package/dist/channel/index.js +0 -0
  3. package/dist/chunk-ZDURDZTM.js +199 -0
  4. package/dist/flows/index.d.ts +246 -0
  5. package/dist/flows/index.js +1110 -0
  6. package/dist/index.d.ts +542 -0
  7. package/dist/index.js +2077 -0
  8. package/dist/styles.css +11 -0
  9. package/dist/styles.d.ts +2 -0
  10. package/package.json +52 -0
  11. package/src/AudioPlayer.tsx +103 -0
  12. package/src/Avatar.tsx +63 -0
  13. package/src/ConversationListItem.tsx +147 -0
  14. package/src/ConversationLocalesProvider.tsx +76 -0
  15. package/src/DateDivider.tsx +32 -0
  16. package/src/EmojiPicker.tsx +77 -0
  17. package/src/FileIcon.tsx +33 -0
  18. package/src/Lightbox.tsx +17 -0
  19. package/src/MediaRenderer.tsx +158 -0
  20. package/src/MessageBubble.tsx +129 -0
  21. package/src/MessageComposer.tsx +211 -0
  22. package/src/MessageTail.tsx +18 -0
  23. package/src/MessageText.tsx +42 -0
  24. package/src/MessageTimestamp.tsx +22 -0
  25. package/src/SimpleEmojiPicker.tsx +72 -0
  26. package/src/StatusTicks.tsx +39 -0
  27. package/src/Toast.tsx +140 -0
  28. package/src/Wallpaper.tsx +13 -0
  29. package/src/WhatsAppMessageEditor.tsx +142 -0
  30. package/src/channel/index.ts +1 -0
  31. package/src/conversations/index.ts +1 -0
  32. package/src/flows/FlowGroupFrame.tsx +21 -0
  33. package/src/flows/FlowGroupHeader.tsx +31 -0
  34. package/src/flows/FlowMapCanvas.tsx +76 -0
  35. package/src/flows/FlowMapNode.tsx +39 -0
  36. package/src/flows/FlowNodeCard.tsx +150 -0
  37. package/src/flows/FlowNodePanel.tsx +356 -0
  38. package/src/flows/FlowPalette.tsx +137 -0
  39. package/src/flows/FlowPortalNode.tsx +30 -0
  40. package/src/flows/FlowWhatsAppPreview.tsx +67 -0
  41. package/src/flows/flowGraph.ts +391 -0
  42. package/src/flows/index.ts +55 -0
  43. package/src/flows/labels.ts +187 -0
  44. package/src/hooks/useAsyncResource.ts +38 -0
  45. package/src/hooks/useConversationContext.ts +23 -0
  46. package/src/hooks/useConversationDocuments.ts +33 -0
  47. package/src/hooks/useConversationList.ts +32 -0
  48. package/src/hooks/useConversationMessages.ts +64 -0
  49. package/src/hooks/useConversationRealtime.ts +50 -0
  50. package/src/index.ts +87 -0
  51. package/src/lib/format.ts +32 -0
  52. package/src/lib/phone.ts +26 -0
  53. package/src/lib/whatsapp-formatting.tsx +215 -0
  54. package/src/providers/ConversationsProvider.tsx +29 -0
  55. package/src/providers/types.ts +54 -0
  56. package/src/settings/TopicsForm.tsx +109 -0
  57. package/src/settings/WelcomeFarewellForm.tsx +118 -0
  58. package/src/settings/WhatsAppCreateTemplateForm.tsx +309 -0
  59. package/src/settings/WhatsAppTemplateSettingsForm.tsx +264 -0
  60. package/src/styles.css +21 -0
  61. package/src/theme.ts +30 -0
  62. package/src/types.ts +44 -0
  63. package/src/useDarkMode.ts +48 -0
  64. package/src/useWaitingNotifications.ts +64 -0
  65. package/tsconfig.json +15 -0
@@ -0,0 +1,11 @@
1
+ /* src/styles.css */
2
+ .cv-wallpaper {
3
+ background-color: #efeae2;
4
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%23d7cfc0' stroke-width='1.2' opacity='0.7'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
5
+ background-repeat: repeat;
6
+ background-size: 100px 100px;
7
+ }
8
+ .dark .cv-wallpaper {
9
+ background-color: #0b141a;
10
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%2319232a' stroke-width='1.2' opacity='0.9'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
11
+ }
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@adatechnology/conversations-ui",
3
+ "version": "0.0.0",
4
+ "description": "WhatsApp conversation UI components β€” parametrizΓ‘vel por endpoint, tema e feature flags",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./flows": {
15
+ "import": "./dist/flows/index.js",
16
+ "types": "./dist/flows/index.d.ts"
17
+ },
18
+ "./whatsapp": {
19
+ "import": "./dist/channel/index.js",
20
+ "types": "./dist/channel/index.d.ts"
21
+ },
22
+ "./styles.css": "./dist/styles.css"
23
+ },
24
+ "main": "dist/index.js",
25
+ "types": "dist/index.d.ts",
26
+ "dependencies": {
27
+ "lucide-react": "^1.21.0",
28
+ "@adatechnology/meta-whatsapp-contracts": "0.1.0"
29
+ },
30
+ "peerDependencies": {
31
+ "react": "^18 || ^19",
32
+ "react-dom": "^18 || ^19",
33
+ "@xyflow/react": "^12"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@xyflow/react": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "tsup": "^8.5.1",
42
+ "typescript": "^5.9.3",
43
+ "@types/react": "^18 || ^19",
44
+ "@types/react-dom": "^18 || ^19"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/styles.css --dts --format esm --external react --external react-dom --external @xyflow/react",
48
+ "build:watch": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/styles.css --watch --dts --format esm --external react --external react-dom --external @xyflow/react",
49
+ "check": "tsc -p tsconfig.json --noEmit",
50
+ "test": "bun test"
51
+ }
52
+ }
@@ -0,0 +1,103 @@
1
+ import { useEffect, useRef, useState, type MouseEvent } from 'react'
2
+ import { Pause, Play } from 'lucide-react'
3
+
4
+ export interface AudioPlayerProps {
5
+ src: string
6
+ isMine?: boolean
7
+ }
8
+
9
+ // Waveform estΓ‘tico com 30 barras de altura pseudoaleatΓ³ria mas estΓ‘vel β€” paridade com
10
+ // financiamento-imobiliario-bot/apps/web/src/components/AudioPlayer.tsx. Waveform real
11
+ // (a partir do Γ‘udio decodificado), velocidade de reproduΓ§Γ£o, gravador e transcriΓ§Γ£o (STT)
12
+ // ficam como enhancement pΓ³s-paridade (fora do escopo desta task).
13
+ const BARS = Array.from({ length: 30 }, (_, i) => {
14
+ const heights = [3, 5, 8, 12, 7, 10, 14, 9, 6, 11, 15, 8, 4, 13, 7, 10, 6, 12, 9, 5, 14, 8, 11, 6, 13, 7, 10, 5, 9, 4]
15
+ return heights[i % heights.length]
16
+ })
17
+
18
+ function fmt(sec: number): string {
19
+ if (!isFinite(sec)) return '0:00'
20
+ const m = Math.floor(sec / 60)
21
+ const s = Math.floor(sec % 60)
22
+ return `${m}:${s.toString().padStart(2, '0')}`
23
+ }
24
+
25
+ export function AudioPlayer({ src, isMine = false }: AudioPlayerProps) {
26
+ const audioRef = useRef<HTMLAudioElement>(null)
27
+ const [playing, setPlaying] = useState(false)
28
+ const [progress, setProgress] = useState(0) // 0-1
29
+ const [duration, setDuration] = useState(0)
30
+ const [current, setCurrent] = useState(0)
31
+
32
+ useEffect(() => {
33
+ const audio = audioRef.current
34
+ if (!audio) return
35
+ const onTime = () => {
36
+ setCurrent(audio.currentTime)
37
+ setProgress(audio.duration ? audio.currentTime / audio.duration : 0)
38
+ }
39
+ const onMeta = () => setDuration(audio.duration)
40
+ const onEnd = () => { setPlaying(false); setProgress(0); setCurrent(0) }
41
+ audio.addEventListener('timeupdate', onTime)
42
+ audio.addEventListener('loadedmetadata', onMeta)
43
+ audio.addEventListener('ended', onEnd)
44
+ return () => {
45
+ audio.removeEventListener('timeupdate', onTime)
46
+ audio.removeEventListener('loadedmetadata', onMeta)
47
+ audio.removeEventListener('ended', onEnd)
48
+ }
49
+ }, [])
50
+
51
+ const toggle = () => {
52
+ const audio = audioRef.current
53
+ if (!audio) return
54
+ if (playing) { audio.pause(); setPlaying(false) }
55
+ else { audio.play(); setPlaying(true) }
56
+ }
57
+
58
+ const seek = (e: MouseEvent<HTMLDivElement>) => {
59
+ const audio = audioRef.current
60
+ if (!audio || !audio.duration) return
61
+ const rect = e.currentTarget.getBoundingClientRect()
62
+ const ratio = (e.clientX - rect.left) / rect.width
63
+ audio.currentTime = ratio * audio.duration
64
+ }
65
+
66
+ const activeBars = Math.round(progress * BARS.length)
67
+
68
+ const playBtn = isMine
69
+ ? 'bg-green-600 hover:bg-green-700 text-white'
70
+ : 'bg-gray-600 hover:bg-gray-700 text-white'
71
+
72
+ const activeBar = isMine ? 'bg-green-700' : 'bg-gray-700'
73
+ const inactiveBar = isMine ? 'bg-green-300' : 'bg-gray-300'
74
+
75
+ return (
76
+ <div className="flex items-center gap-2 w-full" style={{ minWidth: '200px', maxWidth: '260px' }}>
77
+ <audio ref={audioRef} src={src} preload="metadata" />
78
+
79
+ <button
80
+ onClick={toggle}
81
+ className={`flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center transition-colors ${playBtn}`}
82
+ >
83
+ {playing ? <Pause size={16} /> : <Play size={16} className="translate-x-0.5" />}
84
+ </button>
85
+
86
+ <div className="flex-1 flex flex-col gap-1">
87
+ <div className="flex items-end gap-0.5 h-8 cursor-pointer" onClick={seek}>
88
+ {BARS.map((h, i) => (
89
+ <div
90
+ key={i}
91
+ className={`flex-1 rounded-full transition-colors ${i < activeBars ? activeBar : inactiveBar}`}
92
+ style={{ height: `${h * 2}px` }}
93
+ />
94
+ ))}
95
+ </div>
96
+
97
+ <span className="text-gray-400 tabular-nums text-xs">
98
+ {playing || current > 0 ? fmt(current) : fmt(duration)}
99
+ </span>
100
+ </div>
101
+ </div>
102
+ )
103
+ }
package/src/Avatar.tsx ADDED
@@ -0,0 +1,63 @@
1
+ export interface AvatarProps {
2
+ name?: string | null
3
+ avatarUrl?: string
4
+ size?: 'sm' | 'md' | 'lg'
5
+ className?: string
6
+ }
7
+
8
+ const sizeClasses = {
9
+ sm: 'w-6 h-6 text-xs',
10
+ md: 'w-8 h-8 text-sm',
11
+ lg: 'w-10 h-10 text-base',
12
+ }
13
+
14
+ const bgColors = [
15
+ 'bg-blue-500',
16
+ 'bg-green-500',
17
+ 'bg-purple-500',
18
+ 'bg-pink-500',
19
+ 'bg-yellow-500',
20
+ 'bg-red-500',
21
+ 'bg-indigo-500',
22
+ ]
23
+
24
+ function getInitials(name: string | undefined | null): string {
25
+ if (!name) return '?'
26
+ const parts = name.trim().split(' ')
27
+ if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
28
+ return (parts[0]?.substring(0, 2) || '?').toUpperCase()
29
+ }
30
+
31
+ function getBgColor(name: string | undefined | null): string {
32
+ if (!name) return 'bg-gray-400'
33
+ const hash = name.charCodeAt(0) + (name.charCodeAt(name.length - 1) || 0)
34
+ return bgColors[hash % bgColors.length]
35
+ }
36
+
37
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/Avatar.tsx β€”
38
+ // mesmas iniciais (primeira+ΓΊltima letra), mesmo hash de cor, mesmas classes de tamanho.
39
+ export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarProps) {
40
+ const sizeClass = sizeClasses[size]
41
+
42
+ if (avatarUrl) {
43
+ return (
44
+ <img
45
+ src={avatarUrl}
46
+ alt={name || undefined}
47
+ className={`${sizeClass} rounded-full object-cover flex-shrink-0 ${className}`}
48
+ />
49
+ )
50
+ }
51
+
52
+ const initials = getInitials(name)
53
+ const bgColor = getBgColor(name)
54
+
55
+ return (
56
+ <div
57
+ className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 ${className}`}
58
+ title={name || undefined}
59
+ >
60
+ {initials}
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,147 @@
1
+ import { useMemo } from 'react'
2
+ import { Avatar } from './Avatar'
3
+ import { formatPhone } from './lib/phone'
4
+ import type { ConversationSummary } from './providers/types'
5
+
6
+ export interface ConversationListItemProps {
7
+ conversation: ConversationSummary
8
+ active?: boolean
9
+ selected?: boolean
10
+ onClick?: () => void
11
+ onSelect?: (id: string) => void
12
+ }
13
+
14
+ function formatRelativeTime(dateStr: string): string {
15
+ try {
16
+ const date = new Date(dateStr)
17
+ const now = new Date()
18
+ const diffMs = now.getTime() - date.getTime()
19
+ const diffMins = Math.floor(diffMs / 60_000)
20
+ const diffHours = Math.floor(diffMs / 3_600_000)
21
+ const diffDays = Math.floor(diffMs / 86_400_000)
22
+
23
+ if (diffMins < 1) return 'Agora'
24
+ if (diffMins < 60) return `${diffMins}m`
25
+ if (diffHours < 24) return `${diffHours}h`
26
+ if (diffDays < 7) return `${diffDays}d`
27
+ const day = date.getDate().toString().padStart(2, '0')
28
+ const month = (date.getMonth() + 1).toString().padStart(2, '0')
29
+ return `${day}/${month}`
30
+ } catch {
31
+ return ''
32
+ }
33
+ }
34
+
35
+ function getWindowStatus(lastInboundAt: string | null): { label: string; color: string } | null {
36
+ if (!lastInboundAt) return null
37
+
38
+ try {
39
+ const lastInbound = new Date(lastInboundAt).getTime()
40
+ const now = Date.now()
41
+ const hoursElapsed = (now - lastInbound) / 3_600_000
42
+
43
+ if (hoursElapsed <= 12) return { label: 'active', color: '#22c55e' }
44
+ if (hoursElapsed <= 21) return { label: 'approaching', color: '#eab308' }
45
+ if (hoursElapsed <= 24) return { label: 'warning', color: '#f97316' }
46
+ return { label: 'expired', color: '#ef4444' }
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ function lastMessagePreview(conversation: ConversationSummary): { icon: string | null; preview: string } {
53
+ if (!conversation.lastContent) return { icon: null, preview: '' }
54
+
55
+ const content = conversation.lastContent
56
+
57
+ if (content.startsWith('[image]') || content.startsWith('[Image]')) {
58
+ return { icon: '\u{1F5BC}', preview: 'Imagem' }
59
+ }
60
+ if (content.startsWith('[video]') || content.startsWith('[Video]')) {
61
+ return { icon: '\u{1F3AC}', preview: 'VΓ­deo' }
62
+ }
63
+ if (content.startsWith('[audio]') || content.startsWith('[Audio]')) {
64
+ return { icon: '\u{1F3A4}', preview: 'Áudio' }
65
+ }
66
+ if (content.startsWith('[document]') || content.startsWith('[Document]')) {
67
+ return { icon: '\u{1F4C4}', preview: 'Documento' }
68
+ }
69
+ if (content.startsWith('[sticker]') || content.startsWith('[Sticker]')) {
70
+ return { icon: '\u{1F3AF}', preview: 'Sticker' }
71
+ }
72
+
73
+ return { icon: null, preview: content }
74
+ }
75
+
76
+ export const ConversationListItem = ({
77
+ conversation,
78
+ active = false,
79
+ selected = false,
80
+ onClick,
81
+ onSelect,
82
+ }: ConversationListItemProps) => {
83
+ const isActive = active || selected
84
+ const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
85
+ const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
86
+ const name = conversation.clientName || formatPhone(conversation.whatsappNumber) || conversation.whatsappNumber
87
+ const timestamp = formatRelativeTime(conversation.lastAt)
88
+ const isInbound = conversation.lastDirection === 'inbound'
89
+
90
+ const handleClick = () => {
91
+ onClick?.()
92
+ onSelect?.(conversation.id)
93
+ }
94
+
95
+ return (
96
+ <button
97
+ onClick={handleClick}
98
+ className="w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#f0f2f5] border-b border-[#e9edef]"
99
+ style={{
100
+ backgroundColor: isActive ? '#f0f2f5' : 'transparent',
101
+ }}
102
+ >
103
+ <div className="relative flex-shrink-0">
104
+ <Avatar name={name} size="lg" />
105
+ {conversation.waitingHuman && (
106
+ <div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-amber-400 rounded-full border-2 border-white" />
107
+ )}
108
+ </div>
109
+
110
+ <div className="flex-1 min-w-0">
111
+ <div className="flex items-center justify-between gap-2">
112
+ <div className="flex items-center gap-1.5 min-w-0">
113
+ <span className="text-[16px] text-[#111b21] truncate">{name}</span>
114
+ {windowStatus && windowStatus.label === 'expired' && (
115
+ <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="Janela expirada" />
116
+ )}
117
+ {windowStatus && windowStatus.label === 'warning' && (
118
+ <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title="Janela prΓ³xima do fim" />
119
+ )}
120
+ </div>
121
+ {timestamp && (
122
+ <span className="text-xs text-[#667781] flex-shrink-0">{timestamp}</span>
123
+ )}
124
+ </div>
125
+ <div className="flex items-center justify-between mt-0.5">
126
+ <span className="text-sm text-[#667781] truncate max-w-[180px]">
127
+ {preview.icon && <span className="mr-1">{preview.icon}</span>}
128
+ {preview.preview || 'Sem mensagens'}
129
+ </span>
130
+ {conversation.unread > 0 && (
131
+ <span className="bg-[#25d366] text-white text-xs font-semibold rounded-full min-w-[20px] h-5 px-1.5 flex items-center justify-center flex-shrink-0 ml-2">
132
+ {conversation.unread > 99 ? '99+' : conversation.unread}
133
+ </span>
134
+ )}
135
+ </div>
136
+ <div className="flex items-center gap-1.5 mt-1">
137
+ {conversation.mode === 'human' && (
138
+ <span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-purple-100 text-purple-700">Humano</span>
139
+ )}
140
+ {conversation.mode === 'bot' && conversation.waitingHuman && (
141
+ <span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-amber-100 text-amber-700">Aguardando</span>
142
+ )}
143
+ </div>
144
+ </div>
145
+ </button>
146
+ )
147
+ }
@@ -0,0 +1,76 @@
1
+ import React, { createContext, useContext } from 'react'
2
+
3
+ export interface ConversationLocales {
4
+ bubble: {
5
+ customer: string
6
+ bot: string
7
+ agent: string
8
+ document: string
9
+ media: string
10
+ templateLabel: string
11
+ readAt: string
12
+ windowExpired: string
13
+ viewImage: string
14
+ listenAudio: string
15
+ viewVideo: string
16
+ }
17
+ selection: {
18
+ select: string
19
+ }
20
+ dateDivider: {
21
+ today: string
22
+ yesterday: string
23
+ }
24
+ }
25
+
26
+ const DEFAULT_LOCALES: ConversationLocales = {
27
+ bubble: {
28
+ customer: 'Cliente',
29
+ bot: 'Bot',
30
+ agent: 'Atendente',
31
+ document: 'documento',
32
+ media: 'mΓ­dia',
33
+ templateLabel: 'Template',
34
+ readAt: 'Lida Γ s ',
35
+ windowExpired: 'Janela de 24h expirada',
36
+ viewImage: 'Ver imagem',
37
+ listenAudio: 'Ouvir Γ‘udio',
38
+ viewVideo: 'Ver vΓ­deo',
39
+ },
40
+ selection: {
41
+ select: 'Selecionar',
42
+ },
43
+ dateDivider: {
44
+ today: 'Hoje',
45
+ yesterday: 'Ontem',
46
+ },
47
+ }
48
+
49
+ type PartialLocales = {
50
+ [K in keyof ConversationLocales]?: Partial<ConversationLocales[K]>
51
+ }
52
+
53
+ const ConversationLocalesContext = createContext<ConversationLocales>(DEFAULT_LOCALES)
54
+
55
+ export interface ConversationLocalesProviderProps {
56
+ children: React.ReactNode
57
+ locales?: PartialLocales
58
+ }
59
+
60
+ export function ConversationLocalesProvider({ children, locales }: ConversationLocalesProviderProps) {
61
+ const merged: ConversationLocales = {
62
+ bubble: { ...DEFAULT_LOCALES.bubble, ...locales?.bubble },
63
+ selection: { ...DEFAULT_LOCALES.selection, ...locales?.selection },
64
+ dateDivider: { ...DEFAULT_LOCALES.dateDivider, ...locales?.dateDivider },
65
+ }
66
+
67
+ return (
68
+ <ConversationLocalesContext.Provider value={merged}>
69
+ {children}
70
+ </ConversationLocalesContext.Provider>
71
+ )
72
+ }
73
+
74
+ export function useConversationLocales(): ConversationLocales {
75
+ return useContext(ConversationLocalesContext)
76
+ }
@@ -0,0 +1,32 @@
1
+ import { useConversationLocales } from './ConversationLocalesProvider'
2
+ import { isSameDay } from './lib/format'
3
+
4
+ export interface DateDividerProps {
5
+ iso: string
6
+ }
7
+
8
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/DateDivider.tsx β€”
9
+ // sticky no topo do scroll, "Hoje"/"Ontem" localizados, senΓ£o data completa pt-BR.
10
+ export function DateDivider({ iso }: DateDividerProps) {
11
+ const { dateDivider } = useConversationLocales()
12
+
13
+ return (
14
+ <div className="flex justify-center sticky top-0 z-10 my-2 pointer-events-none">
15
+ <span className="bg-white/95 dark:bg-gray-800/95 text-gray-600 dark:text-gray-300 text-xs font-medium px-3 py-1 rounded-lg shadow-sm">
16
+ {formatDividerLabel(iso, dateDivider)}
17
+ </span>
18
+ </div>
19
+ )
20
+ }
21
+
22
+ function formatDividerLabel(iso: string, dateDivider: { today: string; yesterday: string }): string {
23
+ const date = new Date(iso)
24
+ const today = new Date()
25
+ const yesterday = new Date(today)
26
+ yesterday.setDate(today.getDate() - 1)
27
+
28
+ if (isSameDay(date, today)) return dateDivider.today
29
+ if (isSameDay(date, yesterday)) return dateDivider.yesterday
30
+
31
+ return date.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })
32
+ }
@@ -0,0 +1,77 @@
1
+ import { useState, useCallback } from 'react'
2
+
3
+ export interface EmojiPickerProps {
4
+ onSelect: (emoji: string) => void
5
+ className?: string
6
+ }
7
+
8
+ const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
9
+ {
10
+ name: 'Smileys',
11
+ emojis: ['πŸ˜€', 'πŸ˜ƒ', 'πŸ˜„', '😁', 'πŸ˜…', 'πŸ˜‚', '🀣', '😊', 'πŸ˜‡', 'πŸ™‚', 'πŸ˜‰', '😌', '😍', 'πŸ₯°', '😘', 'πŸ˜—', 'πŸ˜‹', 'πŸ˜›', '😜', 'πŸ€ͺ'],
12
+ },
13
+ {
14
+ name: 'Gestures',
15
+ emojis: ['πŸ‘', 'πŸ‘Ž', 'πŸ‘Œ', '✌️', '🀞', '🀟', '🀘', 'πŸ€™', 'πŸ‘‹', '🀚', 'πŸ–οΈ', 'βœ‹', 'πŸ––', 'πŸ‘', 'πŸ™Œ', '🀝', 'πŸ™', '✍️', 'πŸ’…', '🀳'],
16
+ },
17
+ {
18
+ name: 'Hearts',
19
+ emojis: ['❀️', '🧑', 'πŸ’›', 'πŸ’š', 'πŸ’™', 'πŸ’œ', 'πŸ–€', '🀍', '🀎', 'πŸ’”', '❣️', 'πŸ’•', 'πŸ’ž', 'πŸ’“', 'πŸ’—', 'πŸ’–', 'πŸ’˜', 'πŸ’', 'πŸ’Ÿ', 'β™₯️'],
20
+ },
21
+ {
22
+ name: 'Food',
23
+ emojis: ['πŸ”', '🍟', 'πŸ•', '🌭', '🍿', 'πŸ§‚', 'πŸ₯“', 'πŸ₯š', '🍳', 'πŸ§‡', 'πŸ₯ž', '🧈', '🍞', 'πŸ₯', 'πŸ₯¨', 'πŸ₯―', 'πŸ₯–', 'πŸ§€', 'πŸ₯—', 'πŸ₯™'],
24
+ },
25
+ {
26
+ name: 'Drinks',
27
+ emojis: ['β˜•', '🍡', '🍢', '🍾', '🍷', '🍸', '🍹', '🍺', '🍻', 'πŸ₯‚', 'πŸ₯ƒ', 'πŸ₯€', 'πŸ§‹', 'πŸ§ƒ', 'πŸ§‰', '🧊', 'πŸ₯’', '🍽️', '🍴', 'πŸ₯„'],
28
+ },
29
+ {
30
+ name: 'Objects',
31
+ emojis: ['🎁', 'πŸŽ‚', '🎈', 'πŸŽ‰', '🎊', 'πŸŽ€', 'πŸ“±', 'πŸ’»', '⌚', 'πŸ“·', 'πŸ”‘', 'πŸ’°', 'πŸ’³', 'πŸ“', 'πŸ“Œ', 'πŸ“', 'βœ‚οΈ', 'πŸ”', 'πŸ’‘', 'πŸ””'],
32
+ },
33
+ ]
34
+
35
+ export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
36
+ const [activeCategory, setActiveCategory] = useState(0)
37
+
38
+ const handleSelect = useCallback(
39
+ (emoji: string) => {
40
+ onSelect(emoji)
41
+ },
42
+ [onSelect],
43
+ )
44
+
45
+ return (
46
+ <div className={`bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden ${className}`}>
47
+ <div className="flex border-b border-gray-200 overflow-x-auto">
48
+ {EMOJI_CATEGORIES.map((category, index) => (
49
+ <button
50
+ key={category.name}
51
+ onClick={() => setActiveCategory(index)}
52
+ className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
53
+ activeCategory === index
54
+ ? 'border-blue-500 text-blue-600'
55
+ : 'border-transparent text-gray-500 hover:text-gray-700'
56
+ }`}
57
+ >
58
+ {category.emojis[0]} {category.name}
59
+ </button>
60
+ ))}
61
+ </div>
62
+
63
+ <div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
64
+ {EMOJI_CATEGORIES[activeCategory].emojis.map((emoji) => (
65
+ <button
66
+ key={emoji}
67
+ onClick={() => handleSelect(emoji)}
68
+ className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
69
+ aria-label={emoji}
70
+ >
71
+ {emoji}
72
+ </button>
73
+ ))}
74
+ </div>
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,33 @@
1
+ import { FileArchive, FileSpreadsheet, FileText, File as FileGeneric } from 'lucide-react'
2
+
3
+ export interface FileIconProps {
4
+ filename?: string
5
+ mimeType?: string
6
+ size?: number
7
+ className?: string
8
+ }
9
+
10
+ const EXTENSION_STYLE: Record<string, { Icon: typeof FileText; colorClass: string }> = {
11
+ pdf: { Icon: FileText, colorClass: 'text-red-500' },
12
+ doc: { Icon: FileText, colorClass: 'text-blue-500' },
13
+ docx: { Icon: FileText, colorClass: 'text-blue-500' },
14
+ xls: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
15
+ xlsx: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
16
+ zip: { Icon: FileArchive, colorClass: 'text-orange-500' },
17
+ }
18
+
19
+ function getExtension(filename?: string, mimeType?: string): string {
20
+ const fromFilename = filename?.split('.').pop()?.toLowerCase()
21
+ if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
22
+ return mimeType?.split('/')[1]?.toLowerCase() ?? ''
23
+ }
24
+
25
+ // Melhoria sobre a paridade da bolha de documento (T6.7 usava um ΓΊnico Γ­cone genΓ©rico
26
+ // para qualquer tipo de arquivo) β€” Γ­cone e cor variam por extensΓ£o/mimeType.
27
+ export function FileIcon({ filename, mimeType, size = 20, className = '' }: FileIconProps) {
28
+ const extension = getExtension(filename, mimeType)
29
+ const style = EXTENSION_STYLE[extension] ?? { Icon: FileGeneric, colorClass: 'text-gray-500' }
30
+ const { Icon, colorClass } = style
31
+
32
+ return <Icon size={size} className={`${colorClass} ${className}`.trim()} />
33
+ }
@@ -0,0 +1,17 @@
1
+ export interface LightboxProps {
2
+ imageUrl: string
3
+ caption?: string
4
+ onClose: () => void
5
+ }
6
+
7
+ export function Lightbox({ imageUrl, caption, onClose }: LightboxProps) {
8
+ return (
9
+ <div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4" onClick={onClose}>
10
+ <div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
11
+ <img src={imageUrl} alt={caption ?? 'Image'} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
12
+ {caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
13
+ <button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">Fechar</button>
14
+ </div>
15
+ </div>
16
+ )
17
+ }