@adatechnology/conversations-ui 0.1.0-rc.0 → 0.1.0-rc.10

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 (83) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-FWNAWAJP.js +1743 -0
  3. package/dist/flows/index.js +3 -3
  4. package/dist/index.d.ts +619 -136
  5. package/dist/index.js +1064 -1024
  6. package/dist/preview/index.d.ts +330 -0
  7. package/dist/preview/index.js +1281 -0
  8. package/dist/styles.css +198 -0
  9. package/dist/types-B5C1DLu1.d.ts +365 -0
  10. package/package.json +10 -3
  11. package/src/Avatar.tsx +30 -4
  12. package/src/ChannelIcon.tsx +87 -0
  13. package/src/ConversationContextPanel.tsx +106 -0
  14. package/src/ConversationDocumentsPanel.tsx +425 -0
  15. package/src/ConversationHeader.tsx +257 -0
  16. package/src/ConversationListItem.tsx +54 -7
  17. package/src/ConversationLocalesProvider.tsx +16 -0
  18. package/src/ConversationRow.tsx +137 -0
  19. package/src/DateDivider.tsx +16 -3
  20. package/src/DocumentsLibrary.tsx +322 -0
  21. package/src/EmojiPicker.tsx +69 -55
  22. package/src/FileIcon.test.ts +83 -0
  23. package/src/FileIcon.tsx +88 -11
  24. package/src/InteractiveMessage.test.tsx +41 -0
  25. package/src/InteractiveMessage.tsx +143 -0
  26. package/src/Lightbox.tsx +18 -3
  27. package/src/MediaRenderer.tsx +14 -11
  28. package/src/MessageBubble.tsx +54 -5
  29. package/src/MessageComposer.test.tsx +35 -0
  30. package/src/MessageComposer.tsx +73 -19
  31. package/src/Wallpaper.test.tsx +21 -0
  32. package/src/Wallpaper.tsx +54 -6
  33. package/src/WhatsAppMessageEditor.tsx +28 -4
  34. package/src/WindowExpiredNotice.tsx +57 -0
  35. package/src/conversationChannel.test.ts +53 -0
  36. package/src/conversationChannel.ts +146 -0
  37. package/src/conversationTranscript.test.ts +65 -0
  38. package/src/conversationTranscript.ts +64 -0
  39. package/src/conversationWindow.test.ts +90 -0
  40. package/src/conversationWindow.ts +78 -0
  41. package/src/emojiCatalog.test.ts +35 -0
  42. package/src/emojiCatalog.ts +189 -0
  43. package/src/flows/FlowMapCanvas.tsx +2 -2
  44. package/src/hooks/useConversationActions.ts +56 -0
  45. package/src/hooks/useConversationDocuments.ts +15 -9
  46. package/src/hooks/useConversationList.ts +15 -9
  47. package/src/hooks/useConversationMessages.ts +2 -2
  48. package/src/index.ts +112 -16
  49. package/src/lib/cn.test.ts +29 -0
  50. package/src/lib/cn.ts +15 -0
  51. package/src/lib/createMediaUrlResolver.ts +33 -0
  52. package/src/lib/paginated.test.ts +33 -0
  53. package/src/lib/paginated.ts +26 -0
  54. package/src/lib/phone.ts +34 -0
  55. package/src/preview/AudioRecorderButton.tsx +125 -0
  56. package/src/preview/ConversationPreview.tsx +315 -0
  57. package/src/preview/MediaTypesPreview.tsx +87 -0
  58. package/src/preview/audioRecorderFormat.test.ts +67 -0
  59. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  60. package/src/preview/createMockConversationsApi.ts +271 -0
  61. package/src/preview/createMockSSEProvider.ts +40 -0
  62. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  63. package/src/preview/createPreviewWebhookClient.ts +126 -0
  64. package/src/preview/index.ts +54 -0
  65. package/src/preview/mediaTypeOf.test.ts +15 -0
  66. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  67. package/src/preview/mockEventSource.ts +53 -0
  68. package/src/preview/preview.test.ts +177 -0
  69. package/src/preview/previewFileSamples.test.ts +151 -0
  70. package/src/preview/previewFileSamples.ts +74 -0
  71. package/src/preview/previewFixtures.ts +440 -0
  72. package/src/preview/previewMediaSource.test.ts +62 -0
  73. package/src/preview/previewMediaSource.ts +91 -0
  74. package/src/preview/previewStore.ts +193 -0
  75. package/src/preview/startPreviewScript.ts +60 -0
  76. package/src/providers/types.ts +163 -11
  77. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  78. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  79. package/src/styles.css +136 -0
  80. package/src/types.ts +46 -1
  81. package/src/useDarkMode.ts +26 -0
  82. package/src/useIsNarrow.ts +29 -0
  83. package/src/useWaitingNotifications.ts +74 -29
@@ -0,0 +1,257 @@
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
+ /**
56
+ * Utilitário extra que o host pendura no cabeçalho — ícone no desktop, item de menu no celular,
57
+ * como os nativos. Entra por aqui, e não por um slot de ReactNode, porque é isso que preserva o
58
+ * comportamento responsivo: um nó solto viraria um quarto ícone em 375px, sem área de toque.
59
+ */
60
+ export interface ConversationHeaderUtility {
61
+ key: string
62
+ /** Emoji, para casar com os utilitários nativos do cabeçalho. */
63
+ icon: string
64
+ label: string
65
+ run: () => void
66
+ active?: boolean
67
+ }
68
+
69
+ export interface ConversationHeaderProps {
70
+ conversation: ConversationSummary
71
+ busy?: boolean
72
+ onTakeover?: () => void
73
+ onReturnToBot?: () => void
74
+ onFinish?: () => void
75
+ onDownload?: () => void
76
+ onOpenDocuments?: () => void
77
+ documentsOpen?: boolean
78
+ /** Ações do produto que não existem no contrato do pacote (ex.: ferramentas de dev). */
79
+ extraUtilities?: readonly ConversationHeaderUtility[]
80
+ onBack?: () => void
81
+ labels?: Partial<ConversationHeaderLabels>
82
+ className?: string
83
+ classNames?: Partial<ConversationHeaderClassNames>
84
+ }
85
+
86
+ export function ConversationHeader({
87
+ conversation,
88
+ busy = false,
89
+ onTakeover,
90
+ onReturnToBot,
91
+ onFinish,
92
+ onDownload,
93
+ onOpenDocuments,
94
+ documentsOpen = false,
95
+ extraUtilities,
96
+ onBack,
97
+ labels: labelsOverride,
98
+ className,
99
+ classNames,
100
+ }: ConversationHeaderProps) {
101
+ const labels = { ...DEFAULT_CONVERSATION_HEADER_LABELS, ...labelsOverride }
102
+ const isHuman = conversation.mode === 'human'
103
+ const handle = conversation.contactId ?? conversation.whatsappNumber
104
+ const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
105
+ const flag = contactFlag({ handle, channel: conversation.channel })
106
+ const capabilities = capabilitiesOf(conversation.channel)
107
+ const [menuOpen, setMenuOpen] = useState(false)
108
+
109
+ // Utilitários: ícone no desktop, item de menu no celular. Três ícones lado a lado em 375px não
110
+ // caberiam com área de toque decente, e nenhum deles é a ação principal do atendimento.
111
+ const utilities = [
112
+ onOpenDocuments
113
+ ? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
114
+ : undefined,
115
+ onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
116
+ ...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
117
+ ].filter(
118
+ (utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
119
+ Boolean(utility),
120
+ )
121
+
122
+ // Lista única de ações: alimenta os botões do desktop e o menu do celular, para as duas
123
+ // superfícies nunca divergirem sobre o que está disponível.
124
+ const actions = [
125
+ !isHuman && onTakeover
126
+ ? { key: 'takeover', label: labels.takeover, run: onTakeover, className: 'cv-header-action--primary' }
127
+ : undefined,
128
+ isHuman && onReturnToBot ? { key: 'release', label: labels.returnToBot, run: onReturnToBot, className: '' } : undefined,
129
+ isHuman && onFinish
130
+ ? { key: 'finish', label: `✕ ${labels.finish}`, run: onFinish, className: 'cv-header-action--danger' }
131
+ : undefined,
132
+ ].filter((action): action is { key: string; label: string; run: () => void; className: string } => Boolean(action))
133
+
134
+ // No celular utilitários e ações moram no mesmo menu: utilitários primeiro, porque são consulta e
135
+ // não alteram estado da conversa.
136
+ const menuItems = [
137
+ ...utilities.map((utility) => ({
138
+ key: utility.key,
139
+ label: `${utility.icon} ${utility.label}`,
140
+ run: utility.run,
141
+ className: '',
142
+ })),
143
+ ...actions,
144
+ ]
145
+
146
+ // `flex-nowrap` no cabeçalho: com wrap, o nome do cliente ocupava a largura toda em 375px e
147
+ // empurrava as ações para uma segunda linha — e o menu, ancorado ao próprio botão, passava a abrir
148
+ // fora da tela. O bloco de identificação encurta (truncate) em vez de empurrar.
149
+ return (
150
+ <header
151
+ className={cn(
152
+ 'flex flex-nowrap items-center justify-between gap-3 border-b px-4 py-3',
153
+ classNames?.root,
154
+ className,
155
+ )}
156
+ >
157
+ <div className={cn('flex min-w-0 flex-1 items-center gap-3', classNames?.identity)}>
158
+ {/* Voltar existe só em tela estreita, onde lista e conversa não cabem juntas: sem ele, abrir
159
+ uma conversa no celular é um beco sem saída. A classe some acima de 1024px. */}
160
+ {onBack ? (
161
+ <button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back} title={labels.back}>
162
+ ←
163
+ </button>
164
+ ) : null}
165
+ {/* Sem nome, o Avatar cai na silhueta — melhor que dois dígitos do telefone como iniciais. */}
166
+ <Avatar name={conversation.clientName} size="md" />
167
+ <div className="min-w-0">
168
+ <p className={cn('truncate font-medium', classNames?.name)}>{conversation.clientName ?? displayHandle}</p>
169
+ <p className={cn('truncate text-xs text-gray-500', classNames?.meta)}>
170
+ {/* Bandeira derivada do DDI e só quando o identificador é telefone. Estava fixa em 🇧🇷,
171
+ o que rotulava qualquer contato como brasileiro. */}
172
+ {flag ? `${flag} ` : ''}
173
+ {displayHandle}
174
+ {/* Em tela estreita sobra o ícone do canal e o emoji do modo: o rótulo escrito empurra o
175
+ telefone para fora e não diz nada que o ícone já não diga. */}
176
+ <span className="ml-2 inline-flex items-center gap-1">
177
+ <ChannelIcon channel={conversation.channel} />
178
+ <span className="cv-only-wide">{capabilities.label}</span>
179
+ </span>
180
+ <span className="ml-2 cv-only-wide">{isHuman ? labels.humanMode : labels.botMode}</span>
181
+ <span className="ml-1 cv-only-narrow" title={isHuman ? labels.humanMode : labels.botMode}>
182
+ {isHuman ? '🧑‍💼' : '🤖'}
183
+ </span>
184
+ </p>
185
+ </div>
186
+ </div>
187
+
188
+ <div className={cn('flex shrink-0 items-center gap-2', classNames?.actions)}>
189
+ {/* Desktop: utilitários como ícone e ações como botão, tudo visível de uma vez. */}
190
+ <div className={cn('hidden items-center gap-2 lg:flex', classNames?.desktopActions)}>
191
+ {utilities.map((utility) => (
192
+ <button
193
+ key={utility.key}
194
+ type="button"
195
+ onClick={utility.run}
196
+ disabled={busy}
197
+ aria-pressed={utility.active}
198
+ title={utility.label}
199
+ aria-label={utility.label}
200
+ className={`cv-header-icon ${utility.active ? 'cv-header-icon--active' : ''}`}
201
+ >
202
+ {utility.icon}
203
+ </button>
204
+ ))}
205
+ {actions.map((action) => (
206
+ <button
207
+ key={action.key}
208
+ type="button"
209
+ onClick={action.run}
210
+ disabled={busy}
211
+ className={`cv-header-action ${action.className}`}
212
+ >
213
+ {action.label}
214
+ </button>
215
+ ))}
216
+ </div>
217
+
218
+ {/* Celular: um único ⋮ com tudo dentro. Três ícones de 30px lado a lado violavam o mínimo
219
+ de 44px de área de toque e disputavam espaço com o nome do cliente. */}
220
+ {menuItems.length > 0 ? (
221
+ <div className={cn('cv-menu lg:hidden', classNames?.mobileMenu)}>
222
+ <button
223
+ type="button"
224
+ onClick={() => setMenuOpen(!menuOpen)}
225
+ aria-expanded={menuOpen}
226
+ aria-label={labels.moreActions}
227
+ title={labels.moreActions}
228
+ className="cv-header-icon cv-touch"
229
+ >
230
+ ⋮
231
+ </button>
232
+
233
+ {menuOpen ? (
234
+ <div className="cv-menu-panel" role="menu">
235
+ {menuItems.map((item) => (
236
+ <button
237
+ key={item.key}
238
+ type="button"
239
+ role="menuitem"
240
+ onClick={() => {
241
+ setMenuOpen(false)
242
+ item.run()
243
+ }}
244
+ disabled={busy}
245
+ className={`cv-header-action cv-touch ${item.className}`}
246
+ >
247
+ {item.label}
248
+ </button>
249
+ ))}
250
+ </div>
251
+ ) : null}
252
+ </div>
253
+ ) : null}
254
+ </div>
255
+ </header>
256
+ )
257
+ }
@@ -1,14 +1,38 @@
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
+ export interface ConversationListItemLabels {
7
+ /** Tooltip do ponto vermelho: a janela de atendimento de 24h já fechou. */
8
+ expiredWindow: string
9
+ /** Tooltip do ponto laranja: a janela de atendimento está perto de fechar. */
10
+ warningWindow: string
11
+ }
12
+
13
+ export const DEFAULT_CONVERSATION_LIST_ITEM_LABELS: ConversationListItemLabels = {
14
+ expiredWindow: 'Janela expirada',
15
+ warningWindow: 'Janela próxima do fim',
16
+ }
17
+
6
18
  export interface ConversationListItemProps {
7
19
  conversation: ConversationSummary
20
+ labels?: Partial<ConversationListItemLabels>
8
21
  active?: boolean
9
22
  selected?: boolean
10
23
  onClick?: () => void
11
24
  onSelect?: (id: string) => void
25
+ /**
26
+ * Desliga a borda inferior quando o item é composto dentro de outra linha (ver `ConversationRow`):
27
+ * com ela ligada, a borda corta a própria linha ao meio, separando o item do rodapé de status.
28
+ */
29
+ showDivider?: boolean
30
+ /**
31
+ * Desliga o fundo de selecionado. Par do `showDivider`: quando o item é composto dentro de uma
32
+ * linha maior, quem pinta o realce é a linha — senão só o bloco do item fica cinza e o resto
33
+ * (checkbox, pills, barra lateral) continua branco, como se metade da linha estivesse selecionada.
34
+ */
35
+ highlightActive?: boolean
12
36
  }
13
37
 
14
38
  function formatRelativeTime(dateStr: string): string {
@@ -79,12 +103,22 @@ export const ConversationListItem = ({
79
103
  selected = false,
80
104
  onClick,
81
105
  onSelect,
106
+ showDivider = true,
107
+ highlightActive = true,
108
+ labels,
82
109
  }: ConversationListItemProps) => {
110
+ const expiredWindowLabel = labels?.expiredWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.expiredWindow
111
+ const warningWindowLabel = labels?.warningWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.warningWindow
83
112
  const isActive = active || selected
84
113
  const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
85
114
  const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
86
- const name = conversation.clientName || formatPhone(conversation.whatsappNumber) || conversation.whatsappNumber
115
+ // `contactId` é o identificador neutro; `whatsappNumber` fica como fallback enquanto o backend
116
+ // não informa canal.
117
+ const handle = conversation.contactId ?? conversation.whatsappNumber
118
+ const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
119
+ const name = conversation.clientName || displayHandle || handle
87
120
  const timestamp = formatRelativeTime(conversation.lastAt)
121
+ const flag = contactFlag({ handle, channel: conversation.channel })
88
122
  const isInbound = conversation.lastDirection === 'inbound'
89
123
 
90
124
  const handleClick = () => {
@@ -95,13 +129,15 @@ export const ConversationListItem = ({
95
129
  return (
96
130
  <button
97
131
  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]"
132
+ 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
133
  style={{
100
- backgroundColor: isActive ? '#f0f2f5' : 'transparent',
134
+ backgroundColor: isActive && highlightActive ? '#f0f2f5' : 'transparent',
101
135
  }}
102
136
  >
103
137
  <div className="relative flex-shrink-0">
104
- <Avatar name={name} size="lg" />
138
+ {/* Só o nome do cliente: passar o telefone faria o avatar exibir dígitos soltos em vez da
139
+ silhueta de contato sem nome. */}
140
+ <Avatar name={conversation.clientName} size="lg" />
105
141
  {conversation.waitingHuman && (
106
142
  <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
143
  )}
@@ -110,18 +146,29 @@ export const ConversationListItem = ({
110
146
  <div className="flex-1 min-w-0">
111
147
  <div className="flex items-center justify-between gap-2">
112
148
  <div className="flex items-center gap-1.5 min-w-0">
149
+ {/* Sem nome, o título já é o telefone — a bandeira vai nele. */}
150
+ {!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
113
151
  <span className="text-[16px] text-[#111b21] truncate">{name}</span>
114
152
  {windowStatus && windowStatus.label === 'expired' && (
115
- <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="Janela expirada" />
153
+ <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title={expiredWindowLabel} />
116
154
  )}
117
155
  {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" />
156
+ <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title={warningWindowLabel} />
119
157
  )}
120
158
  </div>
121
159
  {timestamp && (
122
160
  <span className="text-xs text-[#667781] flex-shrink-0">{timestamp}</span>
123
161
  )}
124
162
  </div>
163
+ {/* Telefone só como subtítulo quando o título é o nome — repetir o número embaixo dele
164
+ mesmo gastaria uma linha para dizer duas vezes a mesma coisa. */}
165
+ {conversation.clientName ? (
166
+ <div className="flex items-center gap-1 text-xs text-[#667781]">
167
+ {flag ? <span aria-hidden>{flag}</span> : null}
168
+ <span className="truncate">{displayHandle}</span>
169
+ </div>
170
+ ) : null}
171
+
125
172
  <div className="flex items-center justify-between mt-0.5">
126
173
  <span className="text-sm text-[#667781] truncate max-w-[180px]">
127
174
  {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>