@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.4

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 (53) hide show
  1. package/dist/chunk-4R6Y43DQ.js +726 -0
  2. package/dist/chunk-NV2RZ5KT.js +56 -0
  3. package/dist/{chunk-ZDURDZTM.js → chunk-OGRRHQQW.js} +1 -41
  4. package/dist/flows/index.js +6 -4
  5. package/dist/index.d.ts +323 -111
  6. package/dist/index.js +1032 -954
  7. package/dist/preview/index.d.ts +172 -0
  8. package/dist/preview/index.js +576 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-C0PtaO7S.d.ts +207 -0
  11. package/package.json +10 -3
  12. package/src/Avatar.tsx +18 -3
  13. package/src/ChannelIcon.tsx +87 -0
  14. package/src/ConversationContextPanel.tsx +106 -0
  15. package/src/ConversationDocumentsPanel.tsx +107 -0
  16. package/src/ConversationHeader.tsx +239 -0
  17. package/src/ConversationListItem.tsx +36 -5
  18. package/src/ConversationLocalesProvider.tsx +16 -0
  19. package/src/ConversationRow.tsx +137 -0
  20. package/src/DateDivider.tsx +16 -3
  21. package/src/MediaRenderer.tsx +9 -9
  22. package/src/MessageBubble.tsx +24 -2
  23. package/src/MessageComposer.tsx +15 -2
  24. package/src/Wallpaper.tsx +4 -2
  25. package/src/WindowExpiredNotice.tsx +57 -0
  26. package/src/conversationChannel.test.ts +53 -0
  27. package/src/conversationChannel.ts +146 -0
  28. package/src/conversationTranscript.test.ts +65 -0
  29. package/src/conversationTranscript.ts +64 -0
  30. package/src/conversationWindow.test.ts +90 -0
  31. package/src/conversationWindow.ts +78 -0
  32. package/src/flows/FlowMapCanvas.tsx +2 -2
  33. package/src/hooks/useConversationDocuments.ts +4 -2
  34. package/src/index.ts +73 -4
  35. package/src/lib/cn.ts +15 -0
  36. package/src/lib/phone.ts +34 -0
  37. package/src/preview/ConversationPreview.tsx +148 -0
  38. package/src/preview/createMockConversationsApi.ts +111 -0
  39. package/src/preview/createMockSSEProvider.ts +40 -0
  40. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  41. package/src/preview/createPreviewWebhookClient.ts +99 -0
  42. package/src/preview/index.ts +40 -0
  43. package/src/preview/mockEventSource.ts +53 -0
  44. package/src/preview/preview.test.ts +175 -0
  45. package/src/preview/previewFixtures.ts +153 -0
  46. package/src/preview/previewStore.ts +193 -0
  47. package/src/preview/startPreviewScript.ts +60 -0
  48. package/src/providers/types.ts +36 -2
  49. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  50. package/src/styles.css +136 -0
  51. package/src/types.ts +8 -0
  52. package/src/useDarkMode.ts +26 -0
  53. package/src/useIsNarrow.ts +29 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Arquivos trocados na conversa. Sai do transcript e vira lista própria porque anexo é o que o
3
+ * atendente mais precisa reencontrar depois — rolar meses de mensagens para achar um comprovante é
4
+ * o caso que a busca por documento existe para eliminar.
5
+ *
6
+ * Usa `useConversationDocuments`, então funciona com qualquer `ConversationsApi`. Host sem
7
+ * biblioteca de documentos cai no estado vazio, sem quebrar.
8
+ */
9
+
10
+ import { useState } from 'react'
11
+ import { useConversationDocuments } from './hooks/useConversationDocuments'
12
+ import { useConversations } from './providers/ConversationsProvider'
13
+ import { FileIcon } from './FileIcon'
14
+ import { cn } from './lib/cn'
15
+ import { formatFileSize, formatTimestamp } from './lib/format'
16
+
17
+ export interface ConversationDocumentsPanelLabels {
18
+ toggle: string
19
+ title: string
20
+ searchPlaceholder: string
21
+ empty: string
22
+ loading: string
23
+ failure: string
24
+ download: string
25
+ }
26
+
27
+ export const DEFAULT_CONVERSATION_DOCUMENTS_LABELS: ConversationDocumentsPanelLabels = {
28
+ toggle: '📎 Arquivos',
29
+ title: 'Arquivos da conversa',
30
+ searchPlaceholder: 'Buscar arquivo...',
31
+ empty: 'Nenhum arquivo nesta conversa.',
32
+ loading: 'Carregando arquivos…',
33
+ failure: 'Não foi possível carregar os arquivos.',
34
+ download: 'Baixar',
35
+ }
36
+
37
+ export interface ConversationDocumentsPanelClassNames {
38
+ root: string
39
+ body: string
40
+ }
41
+
42
+ export interface ConversationDocumentsPanelProps {
43
+ conversationId: string
44
+ /** Controlado de fora porque o gatilho vive no cabeçalho, junto das outras ações da conversa. */
45
+ open: boolean
46
+ labels?: Partial<ConversationDocumentsPanelLabels>
47
+ className?: string
48
+ classNames?: Partial<ConversationDocumentsPanelClassNames>
49
+ }
50
+
51
+ export function ConversationDocumentsPanel({
52
+ conversationId,
53
+ open,
54
+ labels: labelsOverride,
55
+ className,
56
+ classNames,
57
+ }: ConversationDocumentsPanelProps) {
58
+ const labels = { ...DEFAULT_CONVERSATION_DOCUMENTS_LABELS, ...labelsOverride }
59
+ const context = useConversations()
60
+ const [search, setSearch] = useState('')
61
+
62
+ // Só busca quando o painel abre: a lista de anexos é consulta extra e não deve pesar em toda
63
+ // conversa aberta.
64
+ const { documents, loading, error } = useConversationDocuments(open ? conversationId : undefined, { search })
65
+
66
+ async function handleDownload(uploadId: string): Promise<void> {
67
+ const url = await context?.api.getDocumentUrl(uploadId)
68
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
69
+ }
70
+
71
+ if (!open) return null
72
+
73
+ return (
74
+ <div className={cn('border-b', classNames?.root, className)}>
75
+ <section className={cn('px-4 py-3', classNames?.body)}>
76
+ <p className="mb-2 text-sm font-medium">{labels.title}</p>
77
+ <input
78
+ type="search"
79
+ value={search}
80
+ onChange={(event) => setSearch(event.target.value)}
81
+ placeholder={labels.searchPlaceholder}
82
+ className="mb-2 w-full rounded-md border px-3 py-2 text-sm"
83
+ />
84
+
85
+ {loading ? <p className="text-xs text-gray-500">{labels.loading}</p> : null}
86
+ {error ? <p className="text-xs text-red-600 dark:text-red-400">{labels.failure}</p> : null}
87
+ {!loading && !error && documents.length === 0 ? (
88
+ <p className="text-xs text-gray-500">{labels.empty}</p>
89
+ ) : null}
90
+
91
+ <ul className="space-y-1">
92
+ {documents.map((document) => (
93
+ <li key={document.id} className="flex items-center gap-2 text-xs">
94
+ <FileIcon mimeType={document.mimeType} />
95
+ <span className="min-w-0 flex-1 truncate">{document.filename}</span>
96
+ <span className="text-gray-500">{formatFileSize(document.sizeBytes)}</span>
97
+ <span className="text-gray-500">{formatTimestamp(document.linkedAt)}</span>
98
+ <button type="button" onClick={() => void handleDownload(document.id)} className="cv-header-action">
99
+ {labels.download}
100
+ </button>
101
+ </li>
102
+ ))}
103
+ </ul>
104
+ </section>
105
+ </div>
106
+ )
107
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Cabeçalho do painel de conversa: quem é o cliente, quem está conduzindo (bot ou humano) e as
3
+ * ações de atendimento.
4
+ *
5
+ * Presentacional por decisão de arquitetura: `ConversationsApi` não tem takeover/release, e não é
6
+ * papel do pacote saber a rota de cada host. Quem passa os handlers é o produto.
7
+ */
8
+
9
+ import { useState } from 'react'
10
+
11
+ import type { ConversationSummary } from './providers/types'
12
+ import { capabilitiesOf, contactFlag, formatContactHandle } from './conversationChannel'
13
+ import { cn } from './lib/cn'
14
+ import { Avatar } from './Avatar'
15
+ import { ChannelIcon } from './ChannelIcon'
16
+
17
+ export interface ConversationHeaderLabels {
18
+ botMode: string
19
+ humanMode: string
20
+ returnToBot: string
21
+ finish: string
22
+ takeover: string
23
+ download: string
24
+ documents: string
25
+ back: string
26
+ moreActions: string
27
+ }
28
+
29
+ export const DEFAULT_CONVERSATION_HEADER_LABELS: ConversationHeaderLabels = {
30
+ botMode: '🤖 atendimento automático',
31
+ humanMode: '🧑‍💼 atendimento humano',
32
+ returnToBot: 'Devolver ao bot',
33
+ finish: 'Finalizar',
34
+ takeover: 'Assumir atendimento',
35
+ download: 'Baixar conversa',
36
+ documents: 'Arquivos da conversa',
37
+ back: 'Voltar para a lista',
38
+ moreActions: 'Mais ações',
39
+ }
40
+
41
+ /**
42
+ * Partes estilizáveis do cabeçalho. Cada chave recebe classes que o `cn` funde por cima da base, e
43
+ * conflito de utilitário (padding, gap, borda) fica com o valor do produto.
44
+ */
45
+ export interface ConversationHeaderClassNames {
46
+ root: string
47
+ identity: string
48
+ name: string
49
+ meta: string
50
+ actions: string
51
+ desktopActions: string
52
+ mobileMenu: string
53
+ }
54
+
55
+ export interface ConversationHeaderProps {
56
+ conversation: ConversationSummary
57
+ busy?: boolean
58
+ onTakeover?: () => void
59
+ onReturnToBot?: () => void
60
+ onFinish?: () => void
61
+ onDownload?: () => void
62
+ onOpenDocuments?: () => void
63
+ documentsOpen?: boolean
64
+ onBack?: () => void
65
+ labels?: Partial<ConversationHeaderLabels>
66
+ className?: string
67
+ classNames?: Partial<ConversationHeaderClassNames>
68
+ }
69
+
70
+ export function ConversationHeader({
71
+ conversation,
72
+ busy = false,
73
+ onTakeover,
74
+ onReturnToBot,
75
+ onFinish,
76
+ onDownload,
77
+ onOpenDocuments,
78
+ documentsOpen = false,
79
+ onBack,
80
+ labels: labelsOverride,
81
+ className,
82
+ classNames,
83
+ }: ConversationHeaderProps) {
84
+ const labels = { ...DEFAULT_CONVERSATION_HEADER_LABELS, ...labelsOverride }
85
+ const isHuman = conversation.mode === 'human'
86
+ const handle = conversation.contactId ?? conversation.whatsappNumber
87
+ const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
88
+ const flag = contactFlag({ handle, channel: conversation.channel })
89
+ const capabilities = capabilitiesOf(conversation.channel)
90
+ const [menuOpen, setMenuOpen] = useState(false)
91
+
92
+ // Utilitários: ícone no desktop, item de menu no celular. Três ícones lado a lado em 375px não
93
+ // caberiam com área de toque decente, e nenhum deles é a ação principal do atendimento.
94
+ const utilities = [
95
+ onOpenDocuments
96
+ ? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
97
+ : undefined,
98
+ onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
99
+ ].filter(
100
+ (utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
101
+ Boolean(utility),
102
+ )
103
+
104
+ // Lista única de ações: alimenta os botões do desktop e o menu do celular, para as duas
105
+ // superfícies nunca divergirem sobre o que está disponível.
106
+ const actions = [
107
+ !isHuman && onTakeover
108
+ ? { key: 'takeover', label: labels.takeover, run: onTakeover, className: 'cv-header-action--primary' }
109
+ : undefined,
110
+ isHuman && onReturnToBot ? { key: 'release', label: labels.returnToBot, run: onReturnToBot, className: '' } : undefined,
111
+ isHuman && onFinish
112
+ ? { key: 'finish', label: `✕ ${labels.finish}`, run: onFinish, className: 'cv-header-action--danger' }
113
+ : undefined,
114
+ ].filter((action): action is { key: string; label: string; run: () => void; className: string } => Boolean(action))
115
+
116
+ // No celular utilitários e ações moram no mesmo menu: utilitários primeiro, porque são consulta e
117
+ // não alteram estado da conversa.
118
+ const menuItems = [
119
+ ...utilities.map((utility) => ({
120
+ key: utility.key,
121
+ label: `${utility.icon} ${utility.label}`,
122
+ run: utility.run,
123
+ className: '',
124
+ })),
125
+ ...actions,
126
+ ]
127
+
128
+ // `flex-nowrap` no cabeçalho: com wrap, o nome do cliente ocupava a largura toda em 375px e
129
+ // empurrava as ações para uma segunda linha — e o menu, ancorado ao próprio botão, passava a abrir
130
+ // fora da tela. O bloco de identificação encurta (truncate) em vez de empurrar.
131
+ return (
132
+ <header
133
+ className={cn(
134
+ 'flex flex-nowrap items-center justify-between gap-3 border-b px-4 py-3',
135
+ classNames?.root,
136
+ className,
137
+ )}
138
+ >
139
+ <div className={cn('flex min-w-0 flex-1 items-center gap-3', classNames?.identity)}>
140
+ {/* Voltar existe só em tela estreita, onde lista e conversa não cabem juntas: sem ele, abrir
141
+ uma conversa no celular é um beco sem saída. A classe some acima de 1024px. */}
142
+ {onBack ? (
143
+ <button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back} title={labels.back}>
144
+
145
+ </button>
146
+ ) : null}
147
+ {/* Sem nome, o Avatar cai na silhueta — melhor que dois dígitos do telefone como iniciais. */}
148
+ <Avatar name={conversation.clientName} size="md" />
149
+ <div className="min-w-0">
150
+ <p className={cn('truncate font-medium', classNames?.name)}>{conversation.clientName ?? displayHandle}</p>
151
+ <p className={cn('truncate text-xs text-gray-500', classNames?.meta)}>
152
+ {/* Bandeira derivada do DDI e só quando o identificador é telefone. Estava fixa em 🇧🇷,
153
+ o que rotulava qualquer contato como brasileiro. */}
154
+ {flag ? `${flag} ` : ''}
155
+ {displayHandle}
156
+ {/* Em tela estreita sobra o ícone do canal e o emoji do modo: o rótulo escrito empurra o
157
+ telefone para fora e não diz nada que o ícone já não diga. */}
158
+ <span className="ml-2 inline-flex items-center gap-1">
159
+ <ChannelIcon channel={conversation.channel} />
160
+ <span className="cv-only-wide">{capabilities.label}</span>
161
+ </span>
162
+ <span className="ml-2 cv-only-wide">{isHuman ? labels.humanMode : labels.botMode}</span>
163
+ <span className="ml-1 cv-only-narrow" title={isHuman ? labels.humanMode : labels.botMode}>
164
+ {isHuman ? '🧑‍💼' : '🤖'}
165
+ </span>
166
+ </p>
167
+ </div>
168
+ </div>
169
+
170
+ <div className={cn('flex shrink-0 items-center gap-2', classNames?.actions)}>
171
+ {/* Desktop: utilitários como ícone e ações como botão, tudo visível de uma vez. */}
172
+ <div className={cn('hidden items-center gap-2 lg:flex', classNames?.desktopActions)}>
173
+ {utilities.map((utility) => (
174
+ <button
175
+ key={utility.key}
176
+ type="button"
177
+ onClick={utility.run}
178
+ disabled={busy}
179
+ aria-pressed={utility.active}
180
+ title={utility.label}
181
+ aria-label={utility.label}
182
+ className={`cv-header-icon ${utility.active ? 'cv-header-icon--active' : ''}`}
183
+ >
184
+ {utility.icon}
185
+ </button>
186
+ ))}
187
+ {actions.map((action) => (
188
+ <button
189
+ key={action.key}
190
+ type="button"
191
+ onClick={action.run}
192
+ disabled={busy}
193
+ className={`cv-header-action ${action.className}`}
194
+ >
195
+ {action.label}
196
+ </button>
197
+ ))}
198
+ </div>
199
+
200
+ {/* Celular: um único ⋮ com tudo dentro. Três ícones de 30px lado a lado violavam o mínimo
201
+ de 44px de área de toque e disputavam espaço com o nome do cliente. */}
202
+ {menuItems.length > 0 ? (
203
+ <div className={cn('cv-menu lg:hidden', classNames?.mobileMenu)}>
204
+ <button
205
+ type="button"
206
+ onClick={() => setMenuOpen(!menuOpen)}
207
+ aria-expanded={menuOpen}
208
+ aria-label={labels.moreActions}
209
+ title={labels.moreActions}
210
+ className="cv-header-icon cv-touch"
211
+ >
212
+
213
+ </button>
214
+
215
+ {menuOpen ? (
216
+ <div className="cv-menu-panel" role="menu">
217
+ {menuItems.map((item) => (
218
+ <button
219
+ key={item.key}
220
+ type="button"
221
+ role="menuitem"
222
+ onClick={() => {
223
+ setMenuOpen(false)
224
+ item.run()
225
+ }}
226
+ disabled={busy}
227
+ className={`cv-header-action cv-touch ${item.className}`}
228
+ >
229
+ {item.label}
230
+ </button>
231
+ ))}
232
+ </div>
233
+ ) : null}
234
+ </div>
235
+ ) : null}
236
+ </div>
237
+ </header>
238
+ )
239
+ }
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from 'react'
2
2
  import { Avatar } from './Avatar'
3
- import { formatPhone } from './lib/phone'
3
+ import { contactFlag, formatContactHandle } from './conversationChannel'
4
4
  import type { ConversationSummary } from './providers/types'
5
5
 
6
6
  export interface ConversationListItemProps {
@@ -9,6 +9,17 @@ export interface ConversationListItemProps {
9
9
  selected?: boolean
10
10
  onClick?: () => void
11
11
  onSelect?: (id: string) => void
12
+ /**
13
+ * Desliga a borda inferior quando o item é composto dentro de outra linha (ver `ConversationRow`):
14
+ * com ela ligada, a borda corta a própria linha ao meio, separando o item do rodapé de status.
15
+ */
16
+ showDivider?: boolean
17
+ /**
18
+ * Desliga o fundo de selecionado. Par do `showDivider`: quando o item é composto dentro de uma
19
+ * linha maior, quem pinta o realce é a linha — senão só o bloco do item fica cinza e o resto
20
+ * (checkbox, pills, barra lateral) continua branco, como se metade da linha estivesse selecionada.
21
+ */
22
+ highlightActive?: boolean
12
23
  }
13
24
 
14
25
  function formatRelativeTime(dateStr: string): string {
@@ -79,12 +90,19 @@ export const ConversationListItem = ({
79
90
  selected = false,
80
91
  onClick,
81
92
  onSelect,
93
+ showDivider = true,
94
+ highlightActive = true,
82
95
  }: ConversationListItemProps) => {
83
96
  const isActive = active || selected
84
97
  const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
85
98
  const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
86
- const name = conversation.clientName || formatPhone(conversation.whatsappNumber) || conversation.whatsappNumber
99
+ // `contactId` é o identificador neutro; `whatsappNumber` fica como fallback enquanto o backend
100
+ // não informa canal.
101
+ const handle = conversation.contactId ?? conversation.whatsappNumber
102
+ const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
103
+ const name = conversation.clientName || displayHandle || handle
87
104
  const timestamp = formatRelativeTime(conversation.lastAt)
105
+ const flag = contactFlag({ handle, channel: conversation.channel })
88
106
  const isInbound = conversation.lastDirection === 'inbound'
89
107
 
90
108
  const handleClick = () => {
@@ -95,13 +113,15 @@ export const ConversationListItem = ({
95
113
  return (
96
114
  <button
97
115
  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]"
116
+ className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${highlightActive ? "hover:bg-[#f0f2f5]" : ""} ${showDivider ? "border-b border-[#e9edef]" : ""}`}
99
117
  style={{
100
- backgroundColor: isActive ? '#f0f2f5' : 'transparent',
118
+ backgroundColor: isActive && highlightActive ? '#f0f2f5' : 'transparent',
101
119
  }}
102
120
  >
103
121
  <div className="relative flex-shrink-0">
104
- <Avatar name={name} size="lg" />
122
+ {/* o nome do cliente: passar o telefone faria o avatar exibir dígitos soltos em vez da
123
+ silhueta de contato sem nome. */}
124
+ <Avatar name={conversation.clientName} size="lg" />
105
125
  {conversation.waitingHuman && (
106
126
  <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
127
  )}
@@ -110,6 +130,8 @@ export const ConversationListItem = ({
110
130
  <div className="flex-1 min-w-0">
111
131
  <div className="flex items-center justify-between gap-2">
112
132
  <div className="flex items-center gap-1.5 min-w-0">
133
+ {/* Sem nome, o título já é o telefone — a bandeira vai nele. */}
134
+ {!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
113
135
  <span className="text-[16px] text-[#111b21] truncate">{name}</span>
114
136
  {windowStatus && windowStatus.label === 'expired' && (
115
137
  <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="Janela expirada" />
@@ -122,6 +144,15 @@ export const ConversationListItem = ({
122
144
  <span className="text-xs text-[#667781] flex-shrink-0">{timestamp}</span>
123
145
  )}
124
146
  </div>
147
+ {/* Telefone só como subtítulo quando o título é o nome — repetir o número embaixo dele
148
+ mesmo gastaria uma linha para dizer duas vezes a mesma coisa. */}
149
+ {conversation.clientName ? (
150
+ <div className="flex items-center gap-1 text-xs text-[#667781]">
151
+ {flag ? <span aria-hidden>{flag}</span> : null}
152
+ <span className="truncate">{displayHandle}</span>
153
+ </div>
154
+ ) : null}
155
+
125
156
  <div className="flex items-center justify-between mt-0.5">
126
157
  <span className="text-sm text-[#667781] truncate max-w-[180px]">
127
158
  {preview.icon && <span className="mr-1">{preview.icon}</span>}
@@ -13,6 +13,14 @@ export interface ConversationLocales {
13
13
  viewImage: string
14
14
  listenAudio: string
15
15
  viewVideo: string
16
+ moderationFlagged: string
17
+ mediaLoading: string
18
+ mediaRetry: string
19
+ mediaError: string
20
+ mediaUnavailable: string
21
+ imageAlt: string
22
+ untitledDocument: string
23
+ downloadFile: string
16
24
  }
17
25
  selection: {
18
26
  select: string
@@ -36,6 +44,14 @@ const DEFAULT_LOCALES: ConversationLocales = {
36
44
  viewImage: 'Ver imagem',
37
45
  listenAudio: 'Ouvir áudio',
38
46
  viewVideo: 'Ver vídeo',
47
+ moderationFlagged: 'Linguagem ofensiva',
48
+ mediaLoading: 'Carregando...',
49
+ mediaRetry: 'Erro — tentar novamente',
50
+ mediaError: 'Erro',
51
+ mediaUnavailable: 'Mídia indisponível',
52
+ imageAlt: 'Imagem',
53
+ untitledDocument: 'Documento',
54
+ downloadFile: 'Baixar',
39
55
  },
40
56
  selection: {
41
57
  select: 'Selecionar',
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Linha da inbox operacional: o `ConversationListItem` cuida do visual padrão (avatar, prévia,
3
+ * hora, não lidas) e aqui em volta ficam as affordances de trabalho — seleção em massa, sinal da
4
+ * janela de 24h, tempo parada e retomada de atendimento.
5
+ *
6
+ * Fica no pacote, e não em cada produto, porque nada disto é regra de um negócio específico: é
7
+ * como se opera uma fila de atendimento no WhatsApp.
8
+ */
9
+
10
+ import { ConversationListItem } from './ConversationListItem'
11
+ import { capabilitiesOf } from './conversationChannel'
12
+ import { cn } from './lib/cn'
13
+ import { ChannelIcon } from './ChannelIcon'
14
+ import type { ConversationSummary } from './providers/types'
15
+ import {
16
+ CONVERSATION_WINDOW,
17
+ formatStalledFor,
18
+ windowOf,
19
+ type ConversationWindow,
20
+ } from './conversationWindow'
21
+
22
+
23
+ const WINDOW_TITLE: Record<ConversationWindow, string> = {
24
+ [CONVERSATION_WINDOW.ALL]: '',
25
+ [CONVERSATION_WINDOW.FRESH]: 'Janela de 24h aberta (menos de 12h)',
26
+ [CONVERSATION_WINDOW.WARNING]: 'Janela de 24h fechando (12-21h)',
27
+ [CONVERSATION_WINDOW.CRITICAL]: 'Janela de 24h quase expirada (21-24h)',
28
+ [CONVERSATION_WINDOW.EXPIRED]: 'Janela de 24h expirada — só template',
29
+ }
30
+
31
+ // Abaixo disto a conversa é recente e o aviso de "parada" só faria ruído.
32
+ const STALLED_THRESHOLD_MS = 60 * 60 * 1000
33
+
34
+ const WINDOW_BAR_CLASS: Record<ConversationWindow, string> = {
35
+ [CONVERSATION_WINDOW.ALL]: 'bg-transparent',
36
+ [CONVERSATION_WINDOW.FRESH]: 'bg-green-500',
37
+ [CONVERSATION_WINDOW.WARNING]: 'bg-yellow-500',
38
+ [CONVERSATION_WINDOW.CRITICAL]: 'bg-red-500',
39
+ [CONVERSATION_WINDOW.EXPIRED]: 'bg-gray-300',
40
+ }
41
+
42
+ export type ConversationRowClassNames = {
43
+ root: string
44
+ windowBar: string
45
+ }
46
+
47
+ export type ConversationRowProps = {
48
+ conversation: ConversationSummary
49
+ active: boolean
50
+ selected: boolean
51
+ now: number
52
+ busy: boolean
53
+ onOpen: () => void
54
+ onToggleSelected: () => void
55
+ onTakeover: () => void
56
+ className?: string
57
+ classNames?: Partial<ConversationRowClassNames>
58
+ }
59
+
60
+ export function ConversationRow({
61
+ conversation,
62
+ active,
63
+ selected,
64
+ now,
65
+ busy,
66
+ onOpen,
67
+ onToggleSelected,
68
+ onTakeover,
69
+ className,
70
+ classNames,
71
+ }: ConversationRowProps) {
72
+ const capabilities = capabilitiesOf(conversation.channel)
73
+ const window = windowOf({ lastInboundAt: conversation.lastInboundAt, now, channel: conversation.channel })
74
+ const stalledMs = now - new Date(conversation.lastAt).getTime()
75
+ // "Parada" é sobre a conversa estar sem resposta há tempo, independente de quem conduz. Amarrar
76
+ // ao `waitingHuman` escondia justamente o caso ruim: conversa assumida e esquecida.
77
+ const isStalled = stalledMs > STALLED_THRESHOLD_MS
78
+ const isWaiting = conversation.mode === 'bot' && conversation.waitingHuman
79
+
80
+ // Realce e hover ficam na linha inteira: com eles no item, só o bloco dele ficava cinza enquanto
81
+ // checkbox, barra lateral e pills continuavam brancos — parecia meia linha selecionada.
82
+ return (
83
+ <div className={cn('cv-row flex border-b', active && 'cv-row--active', classNames?.root, className)}>
84
+ {/* Barra lateral com a cor da janela: informa sem competir por espaço com o conteúdo, e não
85
+ duplica as bolinhas que o próprio ConversationListItem já desenha. */}
86
+ <span
87
+ className={cn('w-1 shrink-0', WINDOW_BAR_CLASS[window], classNames?.windowBar)}
88
+ title={WINDOW_TITLE[window]}
89
+ aria-label={WINDOW_TITLE[window]}
90
+ />
91
+
92
+ <div className="min-w-0 flex-1">
93
+ <div className="flex items-start gap-2 px-2 pt-2">
94
+ <input
95
+ type="checkbox"
96
+ checked={selected}
97
+ onChange={onToggleSelected}
98
+ aria-label={`Selecionar conversa ${conversation.whatsappNumber}`}
99
+ className="mt-3"
100
+ />
101
+ <div className="min-w-0 flex-1">
102
+ {/* Sem divisória: a borda do item cortaria esta linha ao meio, separando-o do rodapé
103
+ de status que pertence à mesma conversa. */}
104
+ <ConversationListItem
105
+ conversation={conversation}
106
+ active={active}
107
+ onClick={onOpen}
108
+ showDivider={false}
109
+ highlightActive={false}
110
+ />
111
+ </div>
112
+ </div>
113
+
114
+ <div className="flex flex-wrap items-center gap-1.5 px-4 pb-2 pl-8">
115
+ {/* Canal na linha: numa inbox multicanal, saber de onde a conversa veio muda o que o
116
+ atendente pode fazer nela. */}
117
+ <span className="cv-pill">
118
+ <ChannelIcon channel={conversation.channel} /> {capabilities.label}
119
+ </span>
120
+ {conversation.mode === 'human' ? (
121
+ <span className="cv-pill cv-pill--success">🧑‍💼 em atendimento</span>
122
+ ) : null}
123
+ {isWaiting ? <span className="cv-pill cv-pill--warning">⏳ aguardando atendimento</span> : null}
124
+ {!isWaiting && conversation.mode === 'bot' ? <span className="cv-pill">🤖 bot ativo</span> : null}
125
+ {isStalled ? (
126
+ <span className="cv-pill cv-pill--danger">⏱️ parada há {formatStalledFor(conversation.lastAt, now)}</span>
127
+ ) : null}
128
+ {isWaiting ? (
129
+ <button type="button" onClick={onTakeover} disabled={busy} className="cv-header-action">
130
+ ▶️ Continuar Atendimento
131
+ </button>
132
+ ) : null}
133
+ </div>
134
+ </div>
135
+ </div>
136
+ )
137
+ }
@@ -1,18 +1,31 @@
1
1
  import { useConversationLocales } from './ConversationLocalesProvider'
2
2
  import { isSameDay } from './lib/format'
3
+ import { cn } from './lib/cn'
4
+
5
+ export interface DateDividerClassNames {
6
+ root: string
7
+ label: string
8
+ }
3
9
 
4
10
  export interface DateDividerProps {
5
11
  iso: string
12
+ className?: string
13
+ classNames?: Partial<DateDividerClassNames>
6
14
  }
7
15
 
8
16
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/DateDivider.tsx —
9
17
  // sticky no topo do scroll, "Hoje"/"Ontem" localizados, senão data completa pt-BR.
10
- export function DateDivider({ iso }: DateDividerProps) {
18
+ export function DateDivider({ iso, className, classNames }: DateDividerProps) {
11
19
  const { dateDivider } = useConversationLocales()
12
20
 
13
21
  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">
22
+ <div className={cn('flex justify-center sticky top-0 z-10 my-2 pointer-events-none', classNames?.root, className)}>
23
+ <span
24
+ className={cn(
25
+ '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',
26
+ classNames?.label,
27
+ )}
28
+ >
16
29
  {formatDividerLabel(iso, dateDivider)}
17
30
  </span>
18
31
  </div>