@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.
- package/dist/chunk-4R6Y43DQ.js +726 -0
- package/dist/chunk-NV2RZ5KT.js +56 -0
- package/dist/{chunk-ZDURDZTM.js → chunk-OGRRHQQW.js} +1 -41
- package/dist/flows/index.js +6 -4
- package/dist/index.d.ts +323 -111
- package/dist/index.js +1032 -954
- package/dist/preview/index.d.ts +172 -0
- package/dist/preview/index.js +576 -0
- package/dist/styles.css +198 -0
- package/dist/types-C0PtaO7S.d.ts +207 -0
- package/package.json +10 -3
- package/src/Avatar.tsx +18 -3
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +106 -0
- package/src/ConversationDocumentsPanel.tsx +107 -0
- package/src/ConversationHeader.tsx +239 -0
- package/src/ConversationListItem.tsx +36 -5
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/MediaRenderer.tsx +9 -9
- package/src/MessageBubble.tsx +24 -2
- package/src/MessageComposer.tsx +15 -2
- package/src/Wallpaper.tsx +4 -2
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/flows/FlowMapCanvas.tsx +2 -2
- package/src/hooks/useConversationDocuments.ts +4 -2
- package/src/index.ts +73 -4
- package/src/lib/cn.ts +15 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +148 -0
- package/src/preview/createMockConversationsApi.ts +111 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -0
- package/src/preview/index.ts +40 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +175 -0
- package/src/preview/previewFixtures.ts +153 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +36 -2
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +8 -0
- package/src/useDarkMode.ts +26 -0
- 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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|