@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,158 @@
1
+ import { useState } from 'react'
2
+ import { AudioPlayer } from './AudioPlayer'
3
+ import { FileIcon } from './FileIcon'
4
+ import { useConversationLocales } from './ConversationLocalesProvider'
5
+ import { formatFileSize } from './lib/format'
6
+ import type { MessagePayload } from './types'
7
+
8
+ function resolveMediaSource(message: MessagePayload): string | null {
9
+ if (message.mediaUrl) return message.mediaUrl
10
+ if (message.base64) {
11
+ const prefix = message.mimeType
12
+ ? `data:${message.mimeType};base64,`
13
+ : 'data:application/octet-stream;base64,'
14
+ return prefix + message.base64
15
+ }
16
+ return null
17
+ }
18
+
19
+ function hasLazyRef(message: MessagePayload): boolean {
20
+ return Boolean(message.uploadId || message.mediaId)
21
+ }
22
+
23
+ export type ResolveMediaUrl = (message: MessagePayload) => Promise<string | null>
24
+
25
+ export interface MediaRendererProps {
26
+ message: MessagePayload
27
+ onLightbox: (src: string) => void
28
+ // Porta injetada pelo host para resolver `uploadId`/`mediaId` numa URL assinada sob
29
+ // demanda (lazy) β€” o pacote nunca chama um endpoint fixo. Paridade com o padrΓ£o
30
+ // loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
31
+ // porΓ©m delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
32
+ onResolveUrl?: ResolveMediaUrl
33
+ }
34
+
35
+ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
36
+ const [url, setUrl] = useState<string | null>(null)
37
+ const [loading, setLoading] = useState(false)
38
+ const [error, setError] = useState(false)
39
+
40
+ const load = async () => {
41
+ if (url || loading || !onResolveUrl) return
42
+ setLoading(true)
43
+ setError(false)
44
+ try {
45
+ const resolved = await onResolveUrl(message)
46
+ if (resolved) setUrl(resolved)
47
+ else setError(true)
48
+ } catch {
49
+ setError(true)
50
+ } finally {
51
+ setLoading(false)
52
+ }
53
+ }
54
+
55
+ return { url, loading, error, load }
56
+ }
57
+
58
+ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRendererProps) {
59
+ const { bubble } = useConversationLocales()
60
+ const eagerSrc = resolveMediaSource(message)
61
+ const lazy = useLazyMediaUrl(message, onResolveUrl)
62
+ const src = eagerSrc ?? lazy.url
63
+ const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
64
+
65
+ const lazyButtonClass = 'text-xs text-blue-600 underline flex items-center gap-1'
66
+
67
+ switch (message.type) {
68
+ case 'image':
69
+ case 'sticker': {
70
+ if (!src && canLazyLoad) {
71
+ return (
72
+ <button onClick={lazy.load} className={lazyButtonClass}>
73
+ {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro β€” tentar novamente' : bubble.viewImage}
74
+ </button>
75
+ )
76
+ }
77
+ return (
78
+ <div className="min-w-[200px]">
79
+ {src ? (
80
+ <img src={src} alt={message.caption ?? 'Image'} className="w-full max-h-80 object-cover cursor-pointer hover:opacity-90 transition-opacity" onClick={() => onLightbox(src)} loading="lazy" />
81
+ ) : (
82
+ <div className="w-full h-40 bg-gray-200 flex items-center justify-center text-gray-400">
83
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></svg>
84
+ </div>
85
+ )}
86
+ </div>
87
+ )
88
+ }
89
+ case 'video': {
90
+ if (!src && canLazyLoad) {
91
+ return (
92
+ <button onClick={lazy.load} className={lazyButtonClass}>
93
+ {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro β€” tentar novamente' : bubble.viewVideo}
94
+ </button>
95
+ )
96
+ }
97
+ return (
98
+ <div className="min-w-[200px]">
99
+ {src ? (
100
+ <video src={src} className="w-full max-h-80 rounded-lg" controls preload="metadata"><track kind="captions" /></video>
101
+ ) : (
102
+ <div className="w-full h-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400">
103
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" ry="2" /></svg>
104
+ </div>
105
+ )}
106
+ </div>
107
+ )
108
+ }
109
+ case 'audio': {
110
+ if (!src && canLazyLoad) {
111
+ return (
112
+ <button onClick={lazy.load} className={lazyButtonClass}>
113
+ {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro β€” tentar novamente' : bubble.listenAudio}
114
+ </button>
115
+ )
116
+ }
117
+ return (
118
+ <div className="min-w-[200px]">
119
+ {src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
120
+ <div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">Audio unavailable</div>
121
+ )}
122
+ </div>
123
+ )
124
+ }
125
+ case 'document': {
126
+ const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
127
+ const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
128
+ return (
129
+ <div className="flex items-center gap-3 min-w-[200px]">
130
+ <div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
131
+ <FileIcon filename={message.filename} mimeType={message.mimeType} />
132
+ </div>
133
+ <div className="flex-1 min-w-0">
134
+ <p className="text-sm font-medium truncate">{message.filename ?? 'Document'}</p>
135
+ <p className="text-xs text-gray-500">{sizeLabel ? `${typeLabel} Β· ${sizeLabel}` : typeLabel}</p>
136
+ </div>
137
+ {src ? (
138
+ <a href={src} download={message.filename} className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors" aria-label="Download">
139
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-gray-600"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
140
+ </a>
141
+ ) : canLazyLoad ? (
142
+ <button
143
+ onClick={lazy.load}
144
+ disabled={lazy.loading}
145
+ className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors disabled:opacity-50"
146
+ aria-label="Download"
147
+ >
148
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-gray-600"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
149
+ </button>
150
+ ) : null}
151
+ {lazy.error && <span className="text-xs text-red-500 flex-shrink-0">Erro</span>}
152
+ </div>
153
+ )
154
+ }
155
+ default:
156
+ return null
157
+ }
158
+ }
@@ -0,0 +1,129 @@
1
+ import { useState } from 'react'
2
+ import { Check } from 'lucide-react'
3
+ import type { MessagePayload } from './types'
4
+ import { useConversationLocales } from './ConversationLocalesProvider'
5
+ import { StatusTicks } from './StatusTicks'
6
+ import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
7
+ import { Lightbox } from './Lightbox'
8
+ import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
9
+ import { formatTimestamp, formatDateTime } from './lib/format'
10
+
11
+ export interface MessageBubbleProps {
12
+ message: MessagePayload
13
+ isMine: boolean
14
+ senderName?: string | null
15
+ isFirstInGroup?: boolean
16
+ isSelecting?: boolean
17
+ isSelected?: boolean
18
+ onToggleSelect?: () => void
19
+ onResolveMediaUrl?: ResolveMediaUrl
20
+ }
21
+
22
+ // Hex arbitrΓ‘rios (nΓ£o os tokens `whatsapp.*` do Tailwind) β€” o pacote fica autocontido,
23
+ // sem exigir que o host replique a configuraΓ§Γ£o de tema feita em tailwind.config.ts do bot.
24
+ const BUBBLE_COLOR: Record<string, string> = {
25
+ agent: 'bg-[#d9fdd3] dark:bg-[#005c4b]',
26
+ bot: 'bg-[#d7f0ec] dark:bg-[#0a3d3a]',
27
+ customer: 'bg-white dark:bg-[#202c33]',
28
+ }
29
+
30
+ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
31
+
32
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx β€”
33
+ // cor por sender, tail sΓ³ no isFirstInGroup, agrupamento mt-2/mt-0.5, ring vermelho em
34
+ // falha, tick de status colorido, tooltip de readAt/janela expirada.
35
+ //
36
+ // Requer '@adatechnology/conversations-ui/styles.css' (ConversationWallpaper) ou um
37
+ // tailwind.config do host expondo as cores `whatsapp.*` β€” ver Wallpaper.tsx e T6.2.
38
+ export function MessageBubble({
39
+ message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
40
+ onResolveMediaUrl,
41
+ }: MessageBubbleProps) {
42
+ const { bubble, selection } = useConversationLocales()
43
+ const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
44
+
45
+ const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
46
+ const hasError = message.status === 'failed'
47
+ const isMedia = MEDIA_TYPES.has(message.type)
48
+ const isTemplate = message.type === 'template'
49
+ const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
50
+
51
+ const tooltipText = message.status === 'read' && message.readAt
52
+ ? `${bubble.readAt}${formatDateTime(message.readAt)}`
53
+ : message.status === 'failed'
54
+ ? bubble.windowExpired
55
+ : undefined
56
+
57
+ // Cantinho reto no topo (o "rabinho" do balΓ£o do WhatsApp) sΓ³ na primeira mensagem de um grupo
58
+ // consecutivo do mesmo remetente β€” o resto do grupo fica com os dois cantos superiores arredondados.
59
+ const tailCornerClass = isFirstInGroup ? (isMine ? 'rounded-tr-md' : 'rounded-tl-md') : ''
60
+
61
+ const checkbox = (
62
+ <button
63
+ onClick={(e) => { e.stopPropagation(); onToggleSelect?.() }}
64
+ title={selection.select}
65
+ className={`
66
+ flex-shrink-0 self-end mb-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
67
+ ${isSelected ? 'bg-teal-600 border-teal-600' : 'bg-white/80 dark:bg-black/40 border-black/20 dark:border-white/30'}
68
+ ${isSelecting ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}
69
+ `}
70
+ >
71
+ {isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
72
+ </button>
73
+ )
74
+
75
+ return (
76
+ <div className={`flex items-end gap-1.5 ${isMine ? 'justify-end' : 'justify-start'} group ${isFirstInGroup ? 'mt-2' : 'mt-0.5'}`}>
77
+ {isMine && checkbox}
78
+ <div
79
+ onClick={isSelecting ? onToggleSelect : undefined}
80
+ className={`
81
+ max-w-[75%] sm:max-w-[65%] rounded-2xl ${tailCornerClass} px-2.5 py-1.5 shadow-sm
82
+ ${bubbleColor}
83
+ ${hasError ? 'ring-1 ring-inset ring-red-400' : ''}
84
+ ${isSelecting ? 'cursor-pointer' : ''}
85
+ ${isSelected ? 'ring-2 ring-teal-500' : ''}
86
+ relative
87
+ `}
88
+ >
89
+ {isMine && isFirstInGroup && (message.sender === 'bot' || (message.sender === 'agent' && senderName)) && (
90
+ <div className="text-xs font-semibold mb-0.5 text-teal-700 dark:text-teal-400">
91
+ {displayName}
92
+ </div>
93
+ )}
94
+
95
+ {isMedia ? (
96
+ <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={onResolveMediaUrl} />
97
+ ) : (
98
+ <>
99
+ {isTemplate && (
100
+ <p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mb-0.5">
101
+ <span>πŸ“¨</span>
102
+ <span className="font-medium">
103
+ {bubble.templateLabel}{message.templateName ? ` β€” ${message.templateName}` : ''}
104
+ </span>
105
+ </p>
106
+ )}
107
+ <div className="text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words leading-[19px]">
108
+ {parseWhatsAppFormatting(message.content ?? '')}
109
+ </div>
110
+ </>
111
+ )}
112
+
113
+ <div className="flex items-center justify-end gap-1 mt-0.5 select-none">
114
+ <span className="text-xs text-black/40 dark:text-white/40 font-medium">
115
+ {formatTimestamp(message.timestamp)}
116
+ </span>
117
+ {isMine && message.status && (
118
+ <StatusTicks status={message.status} title={tooltipText} />
119
+ )}
120
+ </div>
121
+ </div>
122
+ {!isMine && checkbox}
123
+
124
+ {lightboxSrc && (
125
+ <Lightbox imageUrl={lightboxSrc} onClose={() => setLightboxSrc(null)} />
126
+ )}
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,211 @@
1
+ import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent } from 'react'
2
+ import type { ConversationsFeatures } from './types'
3
+ import { EmojiPicker } from './EmojiPicker'
4
+
5
+ export interface MessageComposerProps {
6
+ onSend: (text: string) => void
7
+ onAttach?: (file: File) => void
8
+ value?: string
9
+ onChange?: (value: string) => void
10
+ features?: ConversationsFeatures
11
+ placeholder?: string
12
+ maxLength?: number
13
+ disabled?: boolean
14
+ acceptedFileTypes?: string
15
+ }
16
+
17
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/pages/ConversationsPage.tsx:1490
18
+ const DEFAULT_ACCEPTED_FILE_TYPES = 'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip'
19
+
20
+ interface FilePreview {
21
+ file: File
22
+ previewUrl: string
23
+ }
24
+
25
+ export const MessageComposer = ({
26
+ onSend,
27
+ onAttach,
28
+ value: externalValue,
29
+ onChange: externalOnChange,
30
+ features,
31
+ placeholder = 'Digite uma mensagem...',
32
+ maxLength,
33
+ disabled = false,
34
+ acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
35
+ }: MessageComposerProps) => {
36
+ const [internalText, setInternalText] = useState('')
37
+ const [showEmoji, setShowEmoji] = useState(false)
38
+ const [attachments, setAttachments] = useState<FilePreview[]>([])
39
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
40
+ const fileInputRef = useRef<HTMLInputElement>(null)
41
+
42
+ const isControlled = externalValue !== undefined
43
+ const text = isControlled ? externalValue : internalText
44
+
45
+ const setText = useCallback((newText: string) => {
46
+ if (isControlled) {
47
+ externalOnChange?.(newText)
48
+ } else {
49
+ setInternalText(newText)
50
+ }
51
+ }, [isControlled, externalOnChange])
52
+
53
+ const showEmojiButton = features?.emoji !== false
54
+ const showAttachButton = features?.documents !== false
55
+
56
+ const sendMessage = useCallback(() => {
57
+ const trimmed = text.trim()
58
+ if (!trimmed && attachments.length === 0) return
59
+ if (trimmed) onSend(trimmed)
60
+ for (const a of attachments) {
61
+ onAttach?.(a.file)
62
+ URL.revokeObjectURL(a.previewUrl)
63
+ }
64
+ if (!isControlled) setInternalText('')
65
+ setAttachments([])
66
+ setShowEmoji(false)
67
+ if (textareaRef.current) textareaRef.current.style.height = 'auto'
68
+ }, [text, attachments, onSend, onAttach, isControlled])
69
+
70
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
71
+ if (e.key === 'Enter' && !e.shiftKey) {
72
+ e.preventDefault()
73
+ if (!disabled) sendMessage()
74
+ }
75
+ }, [sendMessage, disabled])
76
+
77
+ const handleInput = useCallback(() => {
78
+ const ta = textareaRef.current
79
+ if (!ta) return
80
+ ta.style.height = 'auto'
81
+ ta.style.height = `${Math.min(ta.scrollHeight, 100)}px`
82
+ }, [])
83
+
84
+ const handleEmojiSelect = useCallback((emoji: string) => {
85
+ const ta = textareaRef.current
86
+ if (!ta) { setText(text + emoji); return }
87
+ const start = ta.selectionStart
88
+ const end = ta.selectionEnd
89
+ const newText = text.slice(0, start) + emoji + text.slice(end)
90
+ setText(newText)
91
+ requestAnimationFrame(() => {
92
+ ta.focus()
93
+ ta.setSelectionRange(start + emoji.length, start + emoji.length)
94
+ handleInput()
95
+ })
96
+ }, [text, setText, handleInput])
97
+
98
+ const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
99
+ const files = e.target.files
100
+ if (!files) return
101
+ const previews: FilePreview[] = []
102
+ for (let i = 0; i < files.length; i++) {
103
+ const file = files[i]
104
+ previews.push({ file, previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' })
105
+ }
106
+ setAttachments(prev => [...prev, ...previews])
107
+ if (fileInputRef.current) fileInputRef.current.value = ''
108
+ }, [])
109
+
110
+ const removeAttachment = useCallback((index: number) => {
111
+ setAttachments(prev => {
112
+ const next = [...prev]
113
+ if (next[index].previewUrl) URL.revokeObjectURL(next[index].previewUrl)
114
+ next.splice(index, 1)
115
+ return next
116
+ })
117
+ }, [])
118
+
119
+ const insertFormatting = useCallback((marker: string) => {
120
+ const ta = textareaRef.current
121
+ if (!ta) return
122
+ const start = ta.selectionStart; const end = ta.selectionEnd
123
+ const sel = text.slice(start, end)
124
+ if (sel) {
125
+ setText(text.slice(0, start) + marker + sel + marker + text.slice(end))
126
+ requestAnimationFrame(() => {
127
+ ta.focus()
128
+ ta.setSelectionRange(start + marker.length + sel.length + marker.length, start + marker.length + sel.length + marker.length)
129
+ })
130
+ }
131
+ }, [text, setText])
132
+
133
+ const canSend = text.trim().length > 0 || attachments.length > 0
134
+ const remaining = maxLength ? maxLength - text.length : null
135
+
136
+ return (
137
+ <div>
138
+ {attachments.length > 0 && (
139
+ <div className="flex gap-2 px-1 pb-2 overflow-x-auto">
140
+ {attachments.map((a, i) => (
141
+ <div key={i} className="relative flex-shrink-0">
142
+ {a.previewUrl ? (
143
+ <img src={a.previewUrl} alt="" className="w-16 h-16 object-cover rounded-lg border border-gray-200" />
144
+ ) : (
145
+ <div className="w-16 h-16 bg-gray-100 rounded-lg border border-gray-200 flex items-center justify-center">
146
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
147
+ </div>
148
+ )}
149
+ <button onClick={() => removeAttachment(i)} className="absolute -top-2 -right-2 w-5 h-5 bg-gray-600 text-white rounded-full flex items-center justify-center hover:bg-gray-800 text-xs">βœ•</button>
150
+ </div>
151
+ ))}
152
+ </div>
153
+ )}
154
+
155
+ <div className="flex items-end gap-1.5 bg-[#f0f2f5] rounded-xl px-3 py-2">
156
+ {showEmojiButton && (
157
+ <div className="relative flex-shrink-0">
158
+ <button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label="Emoji">
159
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><circle cx="9" cy="9" r="0.5" fill="currentColor"/><circle cx="15" cy="9" r="0.5" fill="currentColor"/></svg>
160
+ </button>
161
+ {showEmoji && (
162
+ <div className="absolute bottom-full left-0 mb-2 z-10">
163
+ <EmojiPicker onSelect={handleEmojiSelect} />
164
+ </div>
165
+ )}
166
+ </div>
167
+ )}
168
+
169
+ <textarea
170
+ ref={textareaRef}
171
+ value={text}
172
+ onChange={e => { setText(e.target.value); handleInput() }}
173
+ onKeyDown={handleKeyDown}
174
+ placeholder={placeholder}
175
+ rows={1}
176
+ disabled={disabled}
177
+ className="flex-1 resize-none bg-transparent text-[15px] text-[#3b4a54] placeholder-[#8696a0] outline-none py-1.5 max-h-[100px] leading-relaxed"
178
+ style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" }}
179
+ />
180
+
181
+ {showAttachButton && (
182
+ <>
183
+ <input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
184
+ <button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label="Anexar">
185
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
186
+ </button>
187
+ </>
188
+ )}
189
+
190
+ <button
191
+ onClick={sendMessage}
192
+ disabled={!canSend || disabled}
193
+ className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
194
+ canSend && !disabled
195
+ ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
196
+ : 'bg-gray-200 text-gray-400 cursor-not-allowed'
197
+ }`}
198
+ aria-label="Enviar"
199
+ >
200
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
201
+ </button>
202
+ </div>
203
+
204
+ {remaining !== null && (
205
+ <div className="flex justify-end mt-1 pr-1">
206
+ <span className={`text-xs ${remaining < 20 ? 'text-red-500' : 'text-gray-400'}`}>{remaining}</span>
207
+ </div>
208
+ )}
209
+ </div>
210
+ )
211
+ }
@@ -0,0 +1,18 @@
1
+ export interface MessageTailProps {
2
+ isOutbound: boolean
3
+ }
4
+
5
+ export function MessageTail({ isOutbound }: MessageTailProps) {
6
+ if (isOutbound)
7
+ return (
8
+ <svg className="absolute top-0 -right-[5px] w-[5px] h-[11px]" viewBox="0 0 5 11" fill="#d9fdd3">
9
+ <path d="M5 0H0v11c1.5-2 4.5-2 5-4V0z" />
10
+ </svg>
11
+ )
12
+
13
+ return (
14
+ <svg className="absolute top-0 -left-[5px] w-[5px] h-[11px]" viewBox="0 0 5 11" fill="white">
15
+ <path d="M0 0h5v11C3.5 9 .5 9 0 7V0z" />
16
+ </svg>
17
+ )
18
+ }
@@ -0,0 +1,42 @@
1
+ import { useCallback, useState } from 'react'
2
+ import type { MessagePayload } from './types'
3
+ import { useConversations } from './providers/ConversationsProvider'
4
+ import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
5
+
6
+ export interface MessageTextProps {
7
+ message: MessagePayload
8
+ }
9
+
10
+ export function MessageText({ message }: MessageTextProps) {
11
+ const [copied, setCopied] = useState(false)
12
+
13
+ const handleCopy = useCallback(async () => {
14
+ if (!message.content) return
15
+ try {
16
+ await navigator.clipboard.writeText(message.content)
17
+ setCopied(true)
18
+ setTimeout(() => setCopied(false), 2000)
19
+ } catch {
20
+ // Clipboard not available
21
+ }
22
+ }, [message.content])
23
+
24
+ if (message.type === 'template')
25
+ return <p className="text-sm italic text-[#667781]">{message.content ?? 'Template message'}</p>
26
+
27
+ return (
28
+ <div
29
+ onClick={handleCopy}
30
+ className="text-[14.2px] leading-[19px] whitespace-pre-wrap break-words select-all [&_strong]:font-bold [&_em]:italic [&_del]:line-through"
31
+ >
32
+ <span>
33
+ {parseWhatsAppFormatting(message.content ?? '')}
34
+ </span>
35
+ {copied && (
36
+ <span className="absolute top-0 right-0 -translate-y-full bg-[#3b4a54] text-white text-[11px] px-1.5 py-0.5 rounded shadow-lg">
37
+ Copiado!
38
+ </span>
39
+ )}
40
+ </div>
41
+ )
42
+ }
@@ -0,0 +1,22 @@
1
+ import { StatusTicks } from './StatusTicks'
2
+
3
+ export interface MessageTimestampProps {
4
+ timestamp: string
5
+ status?: string
6
+ isOutbound: boolean
7
+ }
8
+
9
+ export function MessageTimestamp({ timestamp, status, isOutbound }: MessageTimestampProps) {
10
+ return (
11
+ <div className="flex items-center justify-end gap-[3px]">
12
+ <span className="text-[11px] text-[#667781] leading-[15px] select-none">
13
+ {timestamp}
14
+ </span>
15
+ {isOutbound && status && (
16
+ <span className="flex items-center -mr-[2px]">
17
+ <StatusTicks status={status} />
18
+ </span>
19
+ )}
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Smile } from 'lucide-react'
3
+
4
+ const EMOJIS = [
5
+ 'πŸ˜€', 'πŸ˜‚', '🀣', '😊', '😍', 'πŸ₯°', '😘', '😜', 'πŸ€ͺ', '😎',
6
+ '🀩', 'πŸ˜‡', 'πŸ™‚', '😏', '😌', 'πŸ˜”', '😒', '😭', '😀', '😑',
7
+ 'πŸ₯Ί', '😰', '😱', 'πŸ₯³', 'πŸ€”', 'πŸ€—', 'πŸ‘', 'πŸ‘Ž', 'πŸ‘', 'πŸ™Œ',
8
+ '🀝', 'πŸ’ͺ', 'πŸ™', '❀️', '🧑', 'πŸ’›', 'πŸ’š', 'πŸ’™', 'πŸ’œ', 'πŸ–€',
9
+ '🀍', 'πŸ’”', 'πŸ”₯', '⭐', 'πŸŽ‰', '✨', 'πŸ’―', 'βœ…', '❌', '⚠️',
10
+ 'πŸš€', 'πŸ’‘', 'πŸ“Œ', 'πŸ“Ž', 'πŸ“', 'πŸ“Š', 'πŸ“ˆ', 'πŸ’°', 'πŸ’³', '🏠',
11
+ '🏦', 'πŸš—', '✈️', '⏰', 'πŸ“…', 'πŸ””', 'πŸ“ž', 'πŸ’¬', 'πŸ—¨οΈ', 'πŸ‘‹',
12
+ 'πŸ€–', '🎯', 'πŸ†', 'πŸ“‹', 'πŸ“„', 'πŸ”', 'πŸŽ“', '🌟', 'πŸ’Ό', 'πŸ›‘οΈ',
13
+ ]
14
+
15
+ export interface SimpleEmojiPickerProps {
16
+ onSelect: (emoji: string) => void
17
+ label?: string
18
+ pickerWidth?: string
19
+ pickerMaxHeight?: string
20
+ }
21
+
22
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/SimpleEmojiPicker.tsx β€”
23
+ // grade ΓΊnica (sem categorias), botΓ£o de alternΓ’ncia com fecho ao clicar fora. Distinto do
24
+ // EmojiPicker categorizado jΓ‘ existente no pacote, que Γ© sempre-aberto/multi-categoria.
25
+ export function SimpleEmojiPicker({ onSelect, label = 'Emojis', pickerWidth = '280px', pickerMaxHeight = '220px' }: SimpleEmojiPickerProps) {
26
+ const [open, setOpen] = useState(false)
27
+ const containerRef = useRef<HTMLDivElement>(null)
28
+
29
+ useEffect(() => {
30
+ if (!open) return
31
+ const handleClickOutside = (e: MouseEvent) => {
32
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
33
+ setOpen(false)
34
+ }
35
+ }
36
+ document.addEventListener('mousedown', handleClickOutside)
37
+ return () => document.removeEventListener('mousedown', handleClickOutside)
38
+ }, [open])
39
+
40
+ return (
41
+ <div ref={containerRef} className="relative flex-shrink-0">
42
+ <button
43
+ type="button"
44
+ onClick={() => setOpen((v) => !v)}
45
+ className="w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 dark:text-gray-500 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 transition-colors"
46
+ title={label}
47
+ >
48
+ <Smile size={15} />
49
+ </button>
50
+
51
+ {open && (
52
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl p-2 z-50">
53
+ <div
54
+ className="grid grid-cols-10 gap-0.5 overflow-y-auto"
55
+ style={{ width: pickerWidth, maxHeight: pickerMaxHeight }}
56
+ >
57
+ {EMOJIS.map((emoji) => (
58
+ <button
59
+ key={emoji}
60
+ type="button"
61
+ onClick={() => { onSelect(emoji); setOpen(false) }}
62
+ className="w-7 h-7 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-lg leading-none transition-colors"
63
+ >
64
+ {emoji}
65
+ </button>
66
+ ))}
67
+ </div>
68
+ </div>
69
+ )}
70
+ </div>
71
+ )
72
+ }