@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,90 @@
1
+ /**
2
+ * A classificação de janela decide se o atendente pode mandar texto livre. Errar a fronteira não
3
+ * quebra a tela — faz o WhatsApp recusar o envio depois, longe da causa.
4
+ */
5
+
6
+ import { describe, expect, it } from 'bun:test'
7
+ import { CONVERSATION_WINDOW, formatStalledFor, windowOf } from './conversationWindow'
8
+ import { CONVERSATION_CHANNEL, contactFlag, formatContactHandle } from './conversationChannel'
9
+
10
+ const NOW = new Date('2026-07-27T12:00:00.000Z').getTime()
11
+ const HOUR = 60 * 60 * 1000
12
+
13
+ function hoursAgo(hours: number): string {
14
+ return new Date(NOW - hours * HOUR).toISOString()
15
+ }
16
+
17
+ describe('windowOf', () => {
18
+ it('classifica por faixa de horas desde o último contato do cliente', () => {
19
+ expect(windowOf({ lastInboundAt: hoursAgo(0), now: NOW })).toBe(CONVERSATION_WINDOW.FRESH)
20
+ expect(windowOf({ lastInboundAt: hoursAgo(11.9), now: NOW })).toBe(CONVERSATION_WINDOW.FRESH)
21
+ expect(windowOf({ lastInboundAt: hoursAgo(15), now: NOW })).toBe(CONVERSATION_WINDOW.WARNING)
22
+ expect(windowOf({ lastInboundAt: hoursAgo(22), now: NOW })).toBe(CONVERSATION_WINDOW.CRITICAL)
23
+ expect(windowOf({ lastInboundAt: hoursAgo(30), now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
24
+ })
25
+
26
+ // As fronteiras são exatamente onde o erro custa caro: 24h em ponto já é recusa da Meta.
27
+ it('trata as fronteiras como início da faixa seguinte', () => {
28
+ expect(windowOf({ lastInboundAt: hoursAgo(12), now: NOW })).toBe(CONVERSATION_WINDOW.WARNING)
29
+ expect(windowOf({ lastInboundAt: hoursAgo(21), now: NOW })).toBe(CONVERSATION_WINDOW.CRITICAL)
30
+ expect(windowOf({ lastInboundAt: hoursAgo(24), now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
31
+ })
32
+
33
+ // Sem inbound não há janela aberta; classificar como expirada evita prometer texto livre.
34
+ it('considera expirada quando o cliente nunca escreveu', () => {
35
+ expect(windowOf({ lastInboundAt: null, now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
36
+ })
37
+
38
+ // O defeito que isto tranca: aplicar a regra do WhatsApp ao chat de site bloquearia o composer
39
+ // num canal onde nada expira.
40
+ it('nunca expira em canal sem janela de sessão', () => {
41
+ expect(windowOf({ lastInboundAt: hoursAgo(720), now: NOW, channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
42
+ CONVERSATION_WINDOW.FRESH,
43
+ )
44
+ expect(windowOf({ lastInboundAt: null, now: NOW, channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
45
+ CONVERSATION_WINDOW.FRESH,
46
+ )
47
+ })
48
+
49
+ it('mantém a regra do WhatsApp quando o canal não é informado', () => {
50
+ expect(windowOf({ lastInboundAt: hoursAgo(30), now: NOW })).toBe(
51
+ windowOf({ lastInboundAt: hoursAgo(30), now: NOW, channel: CONVERSATION_CHANNEL.WHATSAPP }),
52
+ )
53
+ })
54
+ })
55
+
56
+ describe('formatContactHandle', () => {
57
+ it('formata telefone no WhatsApp e arroba nas redes', () => {
58
+ expect(formatContactHandle({ handle: '5511988887777', channel: CONVERSATION_CHANNEL.WHATSAPP })).toBe(
59
+ '+55 (11) 98888-7777',
60
+ )
61
+ expect(formatContactHandle({ handle: 'marina.alves', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe(
62
+ '@marina.alves',
63
+ )
64
+ expect(formatContactHandle({ handle: '@ja.tem', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe('@ja.tem')
65
+ })
66
+
67
+ it('encurta a sessão anônima do chat de site', () => {
68
+ expect(formatContactHandle({ handle: 'sess_9f2a7c41b8', channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
69
+ 'Visitante 7c41b8',
70
+ )
71
+ })
72
+
73
+ // Bandeira em @perfil não significaria nada: o identificador não carrega país.
74
+ it('só devolve bandeira quando o identificador é telefone', () => {
75
+ expect(contactFlag({ handle: '5511988887777' })).toBe('🇧🇷')
76
+ expect(contactFlag({ handle: 'marina.alves', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe('')
77
+ })
78
+ })
79
+
80
+ describe('formatStalledFor', () => {
81
+ it('formata dias, horas e minutos', () => {
82
+ expect(formatStalledFor(hoursAgo(5 * 24 + 6.2), NOW)).toBe('5d 6h:12m')
83
+ expect(formatStalledFor(hoursAgo(3.5), NOW)).toBe('3h:30m')
84
+ expect(formatStalledFor(hoursAgo(0.25), NOW)).toBe('15m')
85
+ })
86
+
87
+ it('não devolve tempo negativo para carimbo no futuro', () => {
88
+ expect(formatStalledFor(new Date(NOW + HOUR).toISOString(), NOW)).toBe('0m')
89
+ })
90
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Janela de sessão: o intervalo em que o canal aceita mensagem livre do atendente. No WhatsApp são
3
+ * 24h desde o último contato do cliente; fora dela só template. Cada canal tem a sua regra — e há
4
+ * canal sem janela nenhuma — então a política vem de `capabilitiesOf`, não de constante fixa.
5
+ *
6
+ * Mora no SDK porque é regra de plataforma, não de produto: todo projeto que usa este pacote
7
+ * precisa dela para saber o que o atendente ainda consegue fazer.
8
+ */
9
+
10
+ import { capabilitiesOf, type ConversationChannel } from './conversationChannel'
11
+
12
+ const MINUTE_MS = 60_000
13
+ const HOUR_MS = 60 * MINUTE_MS
14
+ const DAY_MS = 24 * HOUR_MS
15
+
16
+ export const CONVERSATION_WINDOW = {
17
+ ALL: 'all',
18
+ FRESH: 'fresh',
19
+ WARNING: 'warning',
20
+ CRITICAL: 'critical',
21
+ EXPIRED: 'expired',
22
+ } as const
23
+ export type ConversationWindow = (typeof CONVERSATION_WINDOW)[keyof typeof CONVERSATION_WINDOW]
24
+
25
+ export const WINDOW_FILTERS = [
26
+ { value: CONVERSATION_WINDOW.ALL, label: 'Todas', dotClass: '' },
27
+ { value: CONVERSATION_WINDOW.FRESH, label: '<12h', dotClass: 'bg-green-500' },
28
+ { value: CONVERSATION_WINDOW.WARNING, label: '12-21h', dotClass: 'bg-yellow-500' },
29
+ { value: CONVERSATION_WINDOW.CRITICAL, label: '21-24h', dotClass: 'bg-red-500' },
30
+ { value: CONVERSATION_WINDOW.EXPIRED, label: '>24h', dotClass: 'bg-gray-400' },
31
+ ] as const
32
+
33
+ // Proporções da janela: 12h e 21h numa janela de 24h, mas expressas como fração para acompanharem
34
+ // canais com janela de outro tamanho.
35
+ const WARNING_RATIO = 0.5
36
+ const CRITICAL_RATIO = 0.875
37
+
38
+ export type WindowOfParams = {
39
+ readonly lastInboundAt: string | null
40
+ readonly now: number
41
+ /** Ausente = `whatsapp`. */
42
+ readonly channel?: ConversationChannel | undefined
43
+ }
44
+
45
+ /**
46
+ * Sem `lastInboundAt` o cliente nunca escreveu, então não há janela aberta — classificar como
47
+ * expirada é o comportamento seguro: evita o atendente tentar texto livre e receber recusa.
48
+ *
49
+ * Canal sem janela de sessão (chat de site) é sempre `fresh`: ali nada expira, e marcar expirado
50
+ * bloquearia o composer inventando um limite que a plataforma não impõe.
51
+ */
52
+ export function windowOf(params: WindowOfParams): ConversationWindow {
53
+ const capabilities = capabilitiesOf(params.channel)
54
+ if (!capabilities.hasSessionWindow) return CONVERSATION_WINDOW.FRESH
55
+
56
+ if (!params.lastInboundAt) return CONVERSATION_WINDOW.EXPIRED
57
+
58
+ const windowMs = capabilities.windowHours * HOUR_MS
59
+ const elapsed = params.now - new Date(params.lastInboundAt).getTime()
60
+
61
+ if (elapsed >= windowMs) return CONVERSATION_WINDOW.EXPIRED
62
+ // Faixas proporcionais à janela do canal, e não fixas em 12h/21h: um canal com janela diferente
63
+ // de 24h teria os avisos nos lugares errados.
64
+ if (elapsed >= windowMs * CRITICAL_RATIO) return CONVERSATION_WINDOW.CRITICAL
65
+ if (elapsed >= windowMs * WARNING_RATIO) return CONVERSATION_WINDOW.WARNING
66
+ return CONVERSATION_WINDOW.FRESH
67
+ }
68
+
69
+ export function formatStalledFor(lastAt: string, now: number): string {
70
+ const elapsed = Math.max(0, now - new Date(lastAt).getTime())
71
+ const days = Math.floor(elapsed / DAY_MS)
72
+ const hours = Math.floor((elapsed % DAY_MS) / HOUR_MS)
73
+ const minutes = Math.floor((elapsed % HOUR_MS) / MINUTE_MS)
74
+
75
+ if (days > 0) return `${days}d ${hours}h:${String(minutes).padStart(2, '0')}m`
76
+ if (hours > 0) return `${hours}h:${String(minutes).padStart(2, '0')}m`
77
+ return `${minutes}m`
78
+ }
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from 'react'
2
2
  import { ReactFlow, Background, Controls, MarkerType, type Node, type Edge } from '@xyflow/react'
3
3
  import '@xyflow/react/dist/style.css'
4
- import { useDarkMode } from '../useDarkMode'
4
+ import { useIsDarkTheme } from '../useDarkMode'
5
5
  import { flowMapNodeTypes, type FlowMapNodeData } from './FlowMapNode'
6
6
  import { computeFlowMapLayout, crossFlowTargetsOf, type FlowGraphData } from './flowGraph'
7
7
  import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
@@ -23,7 +23,7 @@ export interface FlowMapCanvasProps {
23
23
  // visão hierárquica onde cada fluxo é um único nó, ligado por saltos "flow:<key>".
24
24
  export function FlowMapCanvas({ graphs, rootKey, onOpenFlow, labels: labelsOverride }: FlowMapCanvasProps) {
25
25
  const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
26
- const { isDark } = useDarkMode()
26
+ const isDark = useIsDarkTheme()
27
27
  const positions = useMemo(() => computeFlowMapLayout(graphs, rootKey), [graphs, rootKey])
28
28
 
29
29
  const nodes = useMemo<Node[]>(
@@ -14,8 +14,10 @@ export interface UseConversationDocumentsResult {
14
14
  refetch: () => Promise<void>
15
15
  }
16
16
 
17
+ // Aceita `undefined` como "ainda não buscar", igual a `useConversationRealtime`: a lista de anexos
18
+ // é consulta extra, e quem só abre o painel sob demanda não deve pagar por ela em toda conversa.
17
19
  export function useConversationDocuments(
18
- conversationId: string,
20
+ conversationId: string | undefined,
19
21
  params?: UseConversationDocumentsParams,
20
22
  ): UseConversationDocumentsResult {
21
23
  const context = useConversations()
@@ -25,7 +27,7 @@ export function useConversationDocuments(
25
27
  const { api } = context
26
28
 
27
29
  const { data, loading, error, refetch } = useAsyncResource(
28
- () => api.getDocuments(conversationId, params),
30
+ () => (conversationId ? api.getDocuments(conversationId, params) : Promise.resolve([])),
29
31
  [conversationId, params?.search, params?.page],
30
32
  )
31
33
 
package/src/index.ts CHANGED
@@ -19,7 +19,60 @@ export { MessageText } from './MessageText'
19
19
  export { MessageTimestamp } from './MessageTimestamp'
20
20
  export { MessageTail } from './MessageTail'
21
21
 
22
- export { useDarkMode } from './useDarkMode'
22
+ // Operação da inbox: janela de 24h da Meta, linha com ações e painéis do atendimento. Regras da
23
+ // plataforma e do ofício de atender, não de um produto — por isso moram aqui.
24
+ export { CONVERSATION_WINDOW, WINDOW_FILTERS, windowOf, formatStalledFor } from './conversationWindow'
25
+ export type { WindowOfParams } from './conversationWindow'
26
+ // Canal de origem: capacidades por plataforma (janela de sessão, reabertura, tipo de identificador).
27
+ export {
28
+ CONVERSATION_CHANNEL,
29
+ DEFAULT_CONVERSATION_CHANNEL,
30
+ CHANNEL_CAPABILITIES,
31
+ channelFiltersFor,
32
+ CHANNEL_FILTER_ALL,
33
+ REOPEN_MECHANISM,
34
+ HANDLE_KIND,
35
+ capabilitiesOf,
36
+ formatContactHandle,
37
+ contactFlag,
38
+ } from './conversationChannel'
39
+ export type {
40
+ ConversationChannel,
41
+ ChannelCapabilities,
42
+ ChannelFilter,
43
+ ChannelFilterOption,
44
+ ReopenMechanism,
45
+ HandleKind,
46
+ FormatContactHandleParams,
47
+ } from './conversationChannel'
48
+ export type { ConversationWindow } from './conversationWindow'
49
+ export { ConversationRow } from './ConversationRow'
50
+ export { ChannelIcon, CHANNEL_BRAND_COLOR } from './ChannelIcon'
51
+ export type { ChannelIconProps } from './ChannelIcon'
52
+ export type { ConversationRowProps, ConversationRowClassNames } from './ConversationRow'
53
+ export { ConversationHeader, DEFAULT_CONVERSATION_HEADER_LABELS } from './ConversationHeader'
54
+ export type {
55
+ ConversationHeaderProps,
56
+ ConversationHeaderLabels,
57
+ ConversationHeaderClassNames,
58
+ } from './ConversationHeader'
59
+ export { ConversationContextPanel, DEFAULT_CONVERSATION_CONTEXT_LABELS } from './ConversationContextPanel'
60
+ export type {
61
+ ConversationContextPanelProps,
62
+ ConversationContextEntry,
63
+ ConversationContextPanelLabels,
64
+ ConversationContextPanelClassNames,
65
+ } from './ConversationContextPanel'
66
+ export { WindowExpiredNotice, isWindowBlocking, DEFAULT_WINDOW_EXPIRED_LABELS } from './WindowExpiredNotice'
67
+ export type { WindowExpiredNoticeProps, WindowExpiredNoticeLabels } from './WindowExpiredNotice'
68
+ export type { ConversationDocumentsPanelClassNames } from './ConversationDocumentsPanel'
69
+ export { ConversationDocumentsPanel, DEFAULT_CONVERSATION_DOCUMENTS_LABELS } from './ConversationDocumentsPanel'
70
+ export type { ConversationDocumentsPanelProps, ConversationDocumentsPanelLabels } from './ConversationDocumentsPanel'
71
+ export { buildTranscriptText, buildTranscriptFilename, downloadTextFile } from './conversationTranscript'
72
+ export type { BuildTranscriptTextParams } from './conversationTranscript'
73
+
74
+ export { useDarkMode, useIsDarkTheme } from './useDarkMode'
75
+ export { useIsNarrow, NARROW_MAX_WIDTH_PX } from './useIsNarrow'
23
76
  export { useWaitingNotifications } from './useWaitingNotifications'
24
77
 
25
78
  export { ConversationsProvider, useConversations } from './providers/ConversationsProvider'
@@ -29,6 +82,11 @@ export { ConversationsProvider, useConversations } from './providers/Conversatio
29
82
  export { WhatsAppTemplateSettingsForm } from './settings/WhatsAppTemplateSettingsForm'
30
83
  export { WhatsAppCreateTemplateForm } from './settings/WhatsAppCreateTemplateForm'
31
84
  export { WelcomeFarewellForm } from './settings/WelcomeFarewellForm'
85
+ export {
86
+ WhatsAppTemplatesSettings,
87
+ TEMPLATE_SETTINGS_TAB,
88
+ DEFAULT_TEMPLATES_SETTINGS_LABELS,
89
+ } from './settings/WhatsAppTemplatesSettings'
32
90
  export { TopicsForm } from './settings/TopicsForm'
33
91
 
34
92
  // Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
@@ -44,17 +102,23 @@ export { formatPhone, phoneInitials } from './lib/phone'
44
102
  export { formatTimestamp, formatFileSize } from './lib/format'
45
103
 
46
104
  export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
47
- export type { ConversationsApi, SSEProvider, ConversationSummary, ConversationDocument } from './providers/types'
105
+ export type {
106
+ ConversationsApi,
107
+ SSEProvider,
108
+ ConversationEventSource,
109
+ ConversationSummary,
110
+ ConversationDocument,
111
+ } from './providers/types'
48
112
 
49
113
  export type { MessageBubbleProps } from './MessageBubble'
50
114
  export type { ConversationWallpaperProps } from './Wallpaper'
51
115
  export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
52
116
  export type { AudioPlayerProps } from './AudioPlayer'
53
117
  export type { EmojiPickerProps } from './EmojiPicker'
54
- export type { MessageComposerProps } from './MessageComposer'
118
+ export type { MessageComposerProps, MessageComposerClassNames } from './MessageComposer'
55
119
  export type { WhatsAppMessageEditorProps } from './WhatsAppMessageEditor'
56
120
  export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
57
- export type { DateDividerProps } from './DateDivider'
121
+ export type { DateDividerProps, DateDividerClassNames } from './DateDivider'
58
122
  export type { AvatarProps } from './Avatar'
59
123
  export type { ConversationListItemProps } from './ConversationListItem'
60
124
  export type { StatusTicksProps } from './StatusTicks'
@@ -77,6 +141,11 @@ export type {
77
141
  WhatsAppCreateTemplateFormLabels,
78
142
  } from './settings/WhatsAppCreateTemplateForm'
79
143
  export type { WelcomeFarewellFormProps, WelcomeFarewellFormLabels } from './settings/WelcomeFarewellForm'
144
+ export type {
145
+ WhatsAppTemplatesSettingsProps,
146
+ WhatsAppTemplatesSettingsLabels,
147
+ TemplateSettingsTab,
148
+ } from './settings/WhatsAppTemplatesSettings'
80
149
  export type { TopicsFormProps, TopicItem, TopicsFormLabels } from './settings/TopicsForm'
81
150
 
82
151
  export type { UseConversationMessagesResult } from './hooks/useConversationMessages'
package/src/lib/cn.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Junta classes resolvendo conflitos de utilitário do Tailwind.
3
+ *
4
+ * Concatenar não basta: a ordem no atributo `class` não decide nada, quem decide é a ordem no CSS
5
+ * gerado. Com `px-4` na base e `px-2` vindo do host, o Tailwind emite `px-2` antes de `px-4` e a
6
+ * base ganharia — justo o caso de quem quer apertar o espaçamento. O `twMerge` descarta a classe
7
+ * base quando o host manda uma da mesma família, então o override do produto sempre vence.
8
+ */
9
+
10
+ import { clsx, type ClassValue } from 'clsx'
11
+ import { twMerge } from 'tailwind-merge'
12
+
13
+ export function cn(...inputs: ClassValue[]): string {
14
+ return twMerge(clsx(inputs))
15
+ }
package/src/lib/phone.ts CHANGED
@@ -20,6 +20,40 @@ export function formatPhone(number: string): string {
20
20
  return number
21
21
  }
22
22
 
23
+ // Mapa explícito em vez de derivar o emoji do código: prefixos são ambíguos (1 = EUA e Canadá,
24
+ // 7 = Rússia e Cazaquistão) e mostrar a bandeira errada é pior do que não mostrar nenhuma.
25
+ const COUNTRY_FLAG_BY_DIAL_CODE: Readonly<Record<string, string>> = {
26
+ '55': '🇧🇷',
27
+ '351': '🇵🇹',
28
+ '34': '🇪🇸',
29
+ '54': '🇦🇷',
30
+ '56': '🇨🇱',
31
+ '57': '🇨🇴',
32
+ '52': '🇲🇽',
33
+ '598': '🇺🇾',
34
+ '595': '🇵🇾',
35
+ '44': '🇬🇧',
36
+ '49': '🇩🇪',
37
+ '39': '🇮🇹',
38
+ '33': '🇫🇷',
39
+ }
40
+
41
+ /**
42
+ * Devolve string vazia quando não reconhece o país — quem renderiza decide se some com o espaço.
43
+ */
44
+ export function phoneCountryFlag(number: string): string {
45
+ const digits = number.replace(/\D/g, '')
46
+
47
+ // Do prefixo mais longo para o mais curto: '55' casaria antes de '551' e daria bandeira errada
48
+ // em países de código de três dígitos.
49
+ for (const length of [3, 2, 1]) {
50
+ const flag = COUNTRY_FLAG_BY_DIAL_CODE[digits.slice(0, length)]
51
+ if (flag) return flag
52
+ }
53
+
54
+ return ''
55
+ }
56
+
23
57
  export function phoneInitials(number: string): string {
24
58
  const digits = number.replace(/\D/g, '')
25
59
  return digits.slice(-2)
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Visão lado-cliente: você digita como se fosse o cliente no WhatsApp e vê o bot responder. A
3
+ * mensagem sai assinada para o webhook real, então o que roda aqui é o mesmo caminho de staging e
4
+ * produção — webhook, parser, motor de conversa.
5
+ *
6
+ * É o layout de conversa de verdade (wallpaper, divisor de data, agrupamento de bolhas), não uma
7
+ * casca de teste: o preview serve para julgar copy e fluxo, e isso só funciona se o que se vê
8
+ * aqui for o que o cliente vê no aparelho dele.
9
+ *
10
+ * O SSE só avisa que algo mudou (`{ direction, sender }`, sem conteúdo), então a chegada de um
11
+ * evento dispara refetch. É assim que o servidor funciona; renderizar direto do evento
12
+ * funcionaria no mock e quebraria em produção.
13
+ */
14
+
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16
+ import type { MessagePayload } from '../types'
17
+ import type { SSEProvider } from '../providers/types'
18
+ import { MessageBubble } from '../MessageBubble'
19
+ import { MessageComposer } from '../MessageComposer'
20
+ import { DateDivider } from '../DateDivider'
21
+ import { ConversationWallpaper } from '../Wallpaper'
22
+ import type { PreviewWebhookClient } from './createPreviewWebhookClient'
23
+
24
+ export type ConversationPreviewProps = {
25
+ client: PreviewWebhookClient
26
+ sse: SSEProvider
27
+ conversationId: string
28
+ loadMessages: (conversationId: string) => Promise<MessagePayload[]>
29
+ placeholder?: string
30
+ }
31
+
32
+ // Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
33
+ // recomeça um grupo (com rabicho e espaçamento maior).
34
+ const GROUPING_WINDOW_MS = 5 * 60 * 1000
35
+
36
+ type RenderedMessage = {
37
+ readonly message: MessagePayload
38
+ readonly isFirstInGroup: boolean
39
+ readonly showDateDivider: boolean
40
+ }
41
+
42
+ function decorate(messages: readonly MessagePayload[]): RenderedMessage[] {
43
+ return messages.map((message, index) => {
44
+ const previous = index > 0 ? messages[index - 1] : undefined
45
+ const currentTime = new Date(message.timestamp).getTime()
46
+ const previousTime = previous ? new Date(previous.timestamp).getTime() : 0
47
+
48
+ return {
49
+ message,
50
+ isFirstInGroup:
51
+ !previous || previous.sender !== message.sender || currentTime - previousTime > GROUPING_WINDOW_MS,
52
+ showDateDivider:
53
+ !previous || new Date(message.timestamp).toDateString() !== new Date(previous.timestamp).toDateString(),
54
+ }
55
+ })
56
+ }
57
+
58
+ export function ConversationPreview({
59
+ client,
60
+ sse,
61
+ conversationId,
62
+ loadMessages,
63
+ placeholder,
64
+ }: ConversationPreviewProps) {
65
+ const [messages, setMessages] = useState<MessagePayload[]>([])
66
+ const [failure, setFailure] = useState<string | undefined>(undefined)
67
+ const loadMessagesRef = useRef(loadMessages)
68
+ const bottomRef = useRef<HTMLDivElement>(null)
69
+ loadMessagesRef.current = loadMessages
70
+
71
+ const refresh = useCallback(async (): Promise<void> => {
72
+ try {
73
+ setMessages(await loadMessagesRef.current(conversationId))
74
+ } catch {
75
+ // Conversa que ainda não existe é o estado normal do primeiro contato — a API responde erro
76
+ // e a tela deve mostrar o transcript vazio, não uma falha.
77
+ setMessages([])
78
+ }
79
+ }, [conversationId])
80
+
81
+ useEffect(() => {
82
+ void refresh()
83
+ }, [refresh])
84
+
85
+ useEffect(() => {
86
+ const source = sse.connectConversationStream(conversationId)
87
+ const handler = (): void => {
88
+ void refresh()
89
+ }
90
+
91
+ source.addEventListener('message', handler)
92
+ return () => {
93
+ source.removeEventListener('message', handler)
94
+ source.close()
95
+ }
96
+ }, [sse, conversationId, refresh])
97
+
98
+ useEffect(() => {
99
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
100
+ }, [messages])
101
+
102
+ const rendered = useMemo(() => decorate(messages), [messages])
103
+
104
+ async function handleSend(text: string): Promise<void> {
105
+ setFailure(undefined)
106
+ try {
107
+ await client.sendText(text)
108
+ // O bot responde de forma assíncrona e o ping do SSE cobre isso; este refresh é para a
109
+ // própria mensagem enviada aparecer na hora, sem esperar o ciclo de evento.
110
+ await refresh()
111
+ } catch (error) {
112
+ // A recusa mais provável é assinatura inválida (segredo divergente do que a API valida), e
113
+ // ela precisa aparecer na tela: silenciada, o sintoma vira "mandei e não aconteceu nada".
114
+ setFailure(error instanceof Error ? error.message : 'Falha ao entregar a mensagem no webhook.')
115
+ }
116
+ }
117
+
118
+ return (
119
+ <div className="flex h-full min-h-0 flex-col">
120
+ <ConversationWallpaper className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
121
+ {rendered.map(({ message, isFirstInGroup, showDateDivider }) => (
122
+ <div key={message.id}>
123
+ {showDateDivider ? <DateDivider iso={message.timestamp} /> : null}
124
+ <MessageBubble
125
+ message={message}
126
+ // Na visão do cliente, "minha" mensagem é a que ele enviou — inbound do ponto de
127
+ // vista do servidor.
128
+ isMine={message.direction === 'inbound'}
129
+ isFirstInGroup={isFirstInGroup}
130
+ />
131
+ </div>
132
+ ))}
133
+ <div ref={bottomRef} />
134
+ </ConversationWallpaper>
135
+
136
+ {failure ? (
137
+ <p role="alert" className="px-4 py-2 text-sm text-red-600 dark:text-red-400">
138
+ {failure}
139
+ </p>
140
+ ) : null}
141
+
142
+ <MessageComposer
143
+ onSend={(text) => void handleSend(text)}
144
+ placeholder={placeholder ?? 'Escreva como o cliente…'}
145
+ />
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `ConversationsApi` servido pelo store em memória. Como o pacote é headless e recebe a API por
3
+ * injeção, o preview de atendimento humano não precisa de servidor, banco nem Meta: é só outra
4
+ * implementação deste mesmo contrato.
5
+ *
6
+ * Toda resposta é assíncrona e passa por um atraso configurável — API instantânea esconde estados
7
+ * de carregamento, e é neles que a inbox costuma mostrar defeito.
8
+ */
9
+
10
+ import type { MessagePayload } from '../types'
11
+ import type { ConversationDocument, ConversationsApi, ConversationSummary } from '../providers/types'
12
+ import type { PreviewStore } from './previewStore'
13
+
14
+ // PNG 1x1 transparente: o suficiente para o MediaRenderer ter algo válido para desenhar.
15
+ const PREVIEW_IMAGE_BASE64 =
16
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBAEAX+XyEgAAAABJRU5ErkJggg=='
17
+
18
+ export type CreateMockConversationsApiParams = {
19
+ readonly store: PreviewStore
20
+ readonly latencyMs?: number
21
+ readonly agentName?: string
22
+ }
23
+
24
+ const DEFAULT_LATENCY_MS = 120
25
+
26
+ export function createMockConversationsApi(params: CreateMockConversationsApiParams): ConversationsApi {
27
+ const latencyMs = params.latencyMs ?? DEFAULT_LATENCY_MS
28
+
29
+ async function withLatency<TResult>(produce: () => TResult): Promise<TResult> {
30
+ await new Promise((resolve) => setTimeout(resolve, latencyMs))
31
+ return produce()
32
+ }
33
+
34
+ return {
35
+ fetchConversations(fetchParams): Promise<ConversationSummary[]> {
36
+ return withLatency(() => {
37
+ const conversations = params.store.listConversations({
38
+ waitingHuman: fetchParams?.waitingHuman,
39
+ search: fetchParams?.search,
40
+ })
41
+
42
+ const limit = fetchParams?.limit ?? conversations.length
43
+ const page = fetchParams?.page ?? 1
44
+ return conversations.slice((page - 1) * limit, page * limit)
45
+ })
46
+ },
47
+
48
+ fetchMessages(conversationId, fetchParams): Promise<MessagePayload[]> {
49
+ return withLatency(() => {
50
+ const messages = params.store.listMessages(conversationId)
51
+ const limit = fetchParams?.limit
52
+ return limit ? messages.slice(-limit) : messages
53
+ })
54
+ },
55
+
56
+ sendMessage(conversationId, text): Promise<MessagePayload> {
57
+ return withLatency(() =>
58
+ params.store.appendMessage({ conversationId, content: text, direction: 'outbound', sender: 'agent' }),
59
+ )
60
+ },
61
+
62
+ sendMedia(conversationId, data): Promise<MessagePayload> {
63
+ return withLatency(() =>
64
+ params.store.appendMessage({
65
+ conversationId,
66
+ content: data.caption ?? data.filename,
67
+ direction: 'outbound',
68
+ sender: 'agent',
69
+ }),
70
+ )
71
+ },
72
+
73
+ sendTemplate(conversationId, data): Promise<void> {
74
+ return withLatency(() => {
75
+ params.store.appendMessage({
76
+ conversationId,
77
+ content: `[template] ${data.templateName}`,
78
+ direction: 'outbound',
79
+ sender: 'agent',
80
+ })
81
+ })
82
+ },
83
+
84
+ markRead(conversationId): Promise<void> {
85
+ return withLatency(() => params.store.markRead(conversationId))
86
+ },
87
+
88
+ getContext(conversationId): Promise<Record<string, unknown>> {
89
+ return withLatency(() => {
90
+ const conversation = params.store.listConversations().find((item) => item.id === conversationId)
91
+ return {
92
+ currentState: conversation?.currentState ?? 'unknown',
93
+ mode: conversation?.mode ?? 'bot',
94
+ preview: true,
95
+ }
96
+ })
97
+ },
98
+
99
+ getDocuments(): Promise<ConversationDocument[]> {
100
+ return withLatency(() => [])
101
+ },
102
+
103
+ getDocumentUrl(): Promise<string> {
104
+ return withLatency(() => `data:image/png;base64,${PREVIEW_IMAGE_BASE64}`)
105
+ },
106
+
107
+ getMediaProxyUrl(): Promise<{ mimeType: string; data: string }> {
108
+ return withLatency(() => ({ mimeType: 'image/png', data: PREVIEW_IMAGE_BASE64 }))
109
+ },
110
+ }
111
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `SSEProvider` servido pelo store em memória. Mesmo mapeamento de canal do servidor
3
+ * (`conv:<conversationId>` e `global`), para que a UI não perceba a troca.
4
+ */
5
+
6
+ import type { SSEProvider } from '../providers/types'
7
+ import { createMockEventSource } from './mockEventSource'
8
+ import { conversationChannel, GLOBAL_CHANNEL, type PreviewStore } from './previewStore'
9
+
10
+ export type CreateMockSSEProviderParams = {
11
+ readonly store: PreviewStore
12
+ }
13
+
14
+ export function createMockSSEProvider(params: CreateMockSSEProviderParams): SSEProvider {
15
+ function connect(channel: string): ReturnType<typeof createMockEventSource> {
16
+ const source = createMockEventSource()
17
+ const unsubscribe = params.store.subscribe(channel, (emission) => {
18
+ source.emit(emission.event, emission.payload)
19
+ })
20
+
21
+ const close = source.close.bind(source)
22
+ // O unsubscribe tem de acontecer no close, senão cada remontagem de componente deixa um
23
+ // listener preso no store e a mesma mensagem chega duplicada na UI.
24
+ source.close = (): void => {
25
+ unsubscribe()
26
+ close()
27
+ }
28
+
29
+ return source
30
+ }
31
+
32
+ return {
33
+ connectConversationStream(conversationId: string) {
34
+ return connect(conversationChannel(conversationId))
35
+ },
36
+ connectGlobalStream() {
37
+ return connect(GLOBAL_CHANNEL)
38
+ },
39
+ }
40
+ }