@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,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
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
3
+
4
+ describe('searchEmojis', () => {
5
+ it('devolve o catálogo inteiro quando não há termo', () => {
6
+ const total = EMOJI_CATEGORIES.reduce((sum, category) => sum + category.entries.length, 0)
7
+ expect(searchEmojis('').length).toBe(total)
8
+ expect(searchEmojis(' ').length).toBe(total)
9
+ })
10
+
11
+ it('acha pela palavra sem acento e com acento', () => {
12
+ const semAcento = searchEmojis('coracao').map((entry) => entry.emoji)
13
+ const comAcento = searchEmojis('coração').map((entry) => entry.emoji)
14
+
15
+ expect(semAcento).toContain('❤️')
16
+ expect(comAcento).toContain('❤️')
17
+ })
18
+
19
+ it('ignora caixa e casa por prefixo, não pelo meio da palavra', () => {
20
+ expect(searchEmojis('CASA').map((entry) => entry.emoji)).toContain('🏠')
21
+ // "sa" está dentro de "casa", mas não é prefixo de nenhuma palavra-chave dela.
22
+ expect(searchEmojis('sa').map((entry) => entry.emoji)).not.toContain('🏠')
23
+ })
24
+
25
+ it('atravessa categorias, não só a aberta', () => {
26
+ const resultados = searchEmojis('pagamento').map((entry) => entry.emoji)
27
+
28
+ expect(resultados).toContain('💳')
29
+ expect(resultados.length).toBeGreaterThan(1)
30
+ })
31
+
32
+ it('devolve vazio quando nada casa, em vez de cair no catálogo inteiro', () => {
33
+ expect(searchEmojis('xyzabc')).toEqual([])
34
+ })
35
+ })
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Catálogo de emojis com palavras-chave em português, usado pela busca do seletor.
3
+ *
4
+ * Fica em módulo próprio (e não dentro do `EmojiPicker`) porque a busca precisa varrer TODAS as
5
+ * categorias, não só a aberta — o índice é montado uma vez, no carregamento, e não a cada tecla.
6
+ *
7
+ * As palavras-chave são de busca, não legendas: incluem sinônimos e formas sem acento, porque quem
8
+ * digita rápido escreve "coracao" e "polegar" com a mesma frequência que a forma correta.
9
+ */
10
+
11
+ export type EmojiEntry = {
12
+ readonly emoji: string
13
+ readonly keywords: readonly string[]
14
+ }
15
+
16
+ export type EmojiCategory = {
17
+ readonly name: string
18
+ readonly entries: readonly EmojiEntry[]
19
+ }
20
+
21
+ export const EMOJI_CATEGORIES: readonly EmojiCategory[] = [
22
+ {
23
+ name: 'Smileys',
24
+ entries: [
25
+ { emoji: '😀', keywords: ['sorriso', 'feliz', 'alegre'] },
26
+ { emoji: '😃', keywords: ['sorriso', 'feliz', 'animado'] },
27
+ { emoji: '😄', keywords: ['sorriso', 'feliz', 'risada'] },
28
+ { emoji: '😁', keywords: ['sorriso', 'dentes', 'feliz'] },
29
+ { emoji: '😅', keywords: ['alivio', 'alívio', 'suor', 'nervoso'] },
30
+ { emoji: '😂', keywords: ['risada', 'chorando', 'engracado', 'engraçado'] },
31
+ { emoji: '🤣', keywords: ['risada', 'rolando', 'engracado', 'engraçado'] },
32
+ { emoji: '😊', keywords: ['sorriso', 'timido', 'tímido', 'feliz'] },
33
+ { emoji: '😇', keywords: ['anjo', 'inocente', 'santo'] },
34
+ { emoji: '🙂', keywords: ['sorriso', 'leve', 'ok'] },
35
+ { emoji: '😉', keywords: ['piscada', 'piscar', 'flerte'] },
36
+ { emoji: '😌', keywords: ['aliviado', 'calmo', 'tranquilo'] },
37
+ { emoji: '😍', keywords: ['amor', 'apaixonado', 'coracao', 'coração', 'olhos'] },
38
+ { emoji: '🥰', keywords: ['amor', 'apaixonado', 'carinho'] },
39
+ { emoji: '😘', keywords: ['beijo', 'amor', 'carinho'] },
40
+ { emoji: '😋', keywords: ['gostoso', 'delicia', 'delícia', 'lingua', 'língua'] },
41
+ { emoji: '😜', keywords: ['lingua', 'língua', 'brincadeira', 'piscada'] },
42
+ { emoji: '🤪', keywords: ['maluco', 'doido', 'brincadeira'] },
43
+ { emoji: '🤔', keywords: ['pensando', 'duvida', 'dúvida', 'hmm'] },
44
+ { emoji: '🤗', keywords: ['abraco', 'abraço', 'carinho'] },
45
+ { emoji: '😐', keywords: ['neutro', 'serio', 'sério', 'indiferente'] },
46
+ { emoji: '😴', keywords: ['dormindo', 'sono', 'cansado'] },
47
+ { emoji: '😭', keywords: ['chorando', 'triste', 'lagrima', 'lágrima'] },
48
+ { emoji: '😢', keywords: ['triste', 'chorando', 'lagrima', 'lágrima'] },
49
+ { emoji: '😡', keywords: ['raiva', 'bravo', 'irritado'] },
50
+ { emoji: '😱', keywords: ['susto', 'medo', 'assustado'] },
51
+ { emoji: '🤯', keywords: ['explodindo', 'chocado', 'surpresa'] },
52
+ { emoji: '😎', keywords: ['oculos', 'óculos', 'legal', 'estiloso'] },
53
+ { emoji: '🥳', keywords: ['festa', 'comemorar', 'aniversario', 'aniversário'] },
54
+ { emoji: '😷', keywords: ['mascara', 'máscara', 'doente', 'saude', 'saúde'] },
55
+ ],
56
+ },
57
+ {
58
+ name: 'Gestos',
59
+ entries: [
60
+ { emoji: '👍', keywords: ['joia', 'jóia', 'polegar', 'ok', 'positivo', 'curtir'] },
61
+ { emoji: '👎', keywords: ['polegar', 'negativo', 'ruim', 'nao', 'não'] },
62
+ { emoji: '👌', keywords: ['ok', 'certo', 'perfeito'] },
63
+ { emoji: '✌️', keywords: ['paz', 'vitoria', 'vitória', 'dois'] },
64
+ { emoji: '🤞', keywords: ['sorte', 'dedos', 'cruzados', 'torcendo'] },
65
+ { emoji: '🤙', keywords: ['chama', 'ligar', 'shaka'] },
66
+ { emoji: '👋', keywords: ['tchau', 'ola', 'olá', 'aceno', 'oi'] },
67
+ { emoji: '✋', keywords: ['mao', 'mão', 'parar', 'pare'] },
68
+ { emoji: '👏', keywords: ['palmas', 'aplauso', 'parabens', 'parabéns'] },
69
+ { emoji: '🙌', keywords: ['comemorar', 'maos', 'mãos', 'sucesso'] },
70
+ { emoji: '🤝', keywords: ['acordo', 'aperto', 'mao', 'mão', 'negocio', 'negócio', 'parceria'] },
71
+ { emoji: '🙏', keywords: ['obrigado', 'reza', 'oracao', 'oração', 'por favor'] },
72
+ { emoji: '✍️', keywords: ['escrever', 'assinar', 'assinatura'] },
73
+ { emoji: '💪', keywords: ['forca', 'força', 'musculo', 'músculo', 'braco', 'braço'] },
74
+ { emoji: '👇', keywords: ['abaixo', 'baixo', 'apontar', 'seta'] },
75
+ { emoji: '👉', keywords: ['direita', 'apontar', 'seta'] },
76
+ { emoji: '☝️', keywords: ['acima', 'cima', 'apontar', 'atencao', 'atenção'] },
77
+ ],
78
+ },
79
+ {
80
+ name: 'Corações',
81
+ entries: [
82
+ { emoji: '❤️', keywords: ['coracao', 'coração', 'amor', 'vermelho'] },
83
+ { emoji: '🧡', keywords: ['coracao', 'coração', 'laranja'] },
84
+ { emoji: '💛', keywords: ['coracao', 'coração', 'amarelo'] },
85
+ { emoji: '💚', keywords: ['coracao', 'coração', 'verde'] },
86
+ { emoji: '💙', keywords: ['coracao', 'coração', 'azul'] },
87
+ { emoji: '💜', keywords: ['coracao', 'coração', 'roxo'] },
88
+ { emoji: '🖤', keywords: ['coracao', 'coração', 'preto'] },
89
+ { emoji: '🤍', keywords: ['coracao', 'coração', 'branco'] },
90
+ { emoji: '💔', keywords: ['coracao', 'coração', 'partido', 'triste'] },
91
+ { emoji: '💕', keywords: ['coracao', 'coração', 'amor', 'casal'] },
92
+ { emoji: '💖', keywords: ['coracao', 'coração', 'brilho', 'amor'] },
93
+ { emoji: '💝', keywords: ['coracao', 'coração', 'presente', 'laco', 'laço'] },
94
+ ],
95
+ },
96
+ {
97
+ name: 'Negócios',
98
+ entries: [
99
+ { emoji: '🏠', keywords: ['casa', 'imovel', 'imóvel', 'residencia', 'residência', 'moradia'] },
100
+ { emoji: '🏡', keywords: ['casa', 'imovel', 'imóvel', 'jardim', 'moradia'] },
101
+ { emoji: '🏢', keywords: ['predio', 'prédio', 'empresa', 'escritorio', 'escritório'] },
102
+ { emoji: '🏦', keywords: ['banco', 'financiamento', 'agencia', 'agência'] },
103
+ { emoji: '🔑', keywords: ['chave', 'casa', 'entrega', 'imovel', 'imóvel'] },
104
+ { emoji: '📄', keywords: ['documento', 'papel', 'contrato', 'arquivo'] },
105
+ { emoji: '📋', keywords: ['prancheta', 'lista', 'documento', 'checklist'] },
106
+ { emoji: '📝', keywords: ['anotar', 'escrever', 'nota', 'formulario', 'formulário'] },
107
+ { emoji: '✅', keywords: ['ok', 'certo', 'aprovado', 'concluido', 'concluído', 'check'] },
108
+ { emoji: '❌', keywords: ['errado', 'negado', 'recusado', 'cancelar'] },
109
+ { emoji: '⚠️', keywords: ['atencao', 'atenção', 'alerta', 'cuidado'] },
110
+ { emoji: '💰', keywords: ['dinheiro', 'valor', 'saco', 'grana', 'pagamento'] },
111
+ { emoji: '💵', keywords: ['dinheiro', 'nota', 'valor', 'pagamento'] },
112
+ { emoji: '💳', keywords: ['cartao', 'cartão', 'credito', 'crédito', 'pagamento'] },
113
+ { emoji: '🧾', keywords: ['recibo', 'nota', 'fiscal', 'comprovante'] },
114
+ { emoji: '📊', keywords: ['grafico', 'gráfico', 'relatorio', 'relatório', 'dados'] },
115
+ { emoji: '📈', keywords: ['grafico', 'gráfico', 'subindo', 'crescimento', 'alta'] },
116
+ { emoji: '📉', keywords: ['grafico', 'gráfico', 'caindo', 'queda', 'baixa'] },
117
+ { emoji: '🗓️', keywords: ['calendario', 'calendário', 'data', 'agenda', 'prazo'] },
118
+ { emoji: '⏰', keywords: ['relogio', 'relógio', 'hora', 'prazo', 'alarme'] },
119
+ { emoji: '📞', keywords: ['telefone', 'ligar', 'contato', 'chamada'] },
120
+ { emoji: '📱', keywords: ['celular', 'telefone', 'whatsapp', 'contato'] },
121
+ { emoji: '📧', keywords: ['email', 'e-mail', 'mensagem', 'contato'] },
122
+ { emoji: '📎', keywords: ['anexo', 'clipe', 'arquivo'] },
123
+ { emoji: '🔍', keywords: ['buscar', 'procurar', 'lupa', 'pesquisa', 'consulta'] },
124
+ { emoji: '🤖', keywords: ['robo', 'robô', 'bot', 'assistente', 'automatico', 'automático'] },
125
+ { emoji: '💬', keywords: ['mensagem', 'conversa', 'balao', 'balão', 'chat'] },
126
+ ],
127
+ },
128
+ {
129
+ name: 'Objetos',
130
+ entries: [
131
+ { emoji: '🎁', keywords: ['presente', 'brinde', 'surpresa'] },
132
+ { emoji: '🎉', keywords: ['festa', 'comemorar', 'parabens', 'parabéns'] },
133
+ { emoji: '🎊', keywords: ['festa', 'confete', 'comemorar'] },
134
+ { emoji: '🎂', keywords: ['bolo', 'aniversario', 'aniversário', 'festa'] },
135
+ { emoji: '💡', keywords: ['ideia', 'ideía', 'lampada', 'lâmpada', 'dica'] },
136
+ { emoji: '🔔', keywords: ['sino', 'aviso', 'notificacao', 'notificação', 'lembrete'] },
137
+ { emoji: '⭐', keywords: ['estrela', 'favorito', 'avaliacao', 'avaliação'] },
138
+ { emoji: '🔥', keywords: ['fogo', 'quente', 'destaque', 'top'] },
139
+ { emoji: '🚀', keywords: ['foguete', 'rapido', 'rápido', 'lancamento', 'lançamento'] },
140
+ { emoji: '💻', keywords: ['computador', 'notebook', 'trabalho'] },
141
+ { emoji: '📷', keywords: ['foto', 'camera', 'câmera', 'imagem'] },
142
+ { emoji: '🚗', keywords: ['carro', 'veiculo', 'veículo', 'automovel', 'automóvel'] },
143
+ { emoji: '✈️', keywords: ['aviao', 'avião', 'viagem', 'voo'] },
144
+ ],
145
+ },
146
+ {
147
+ name: 'Comida',
148
+ entries: [
149
+ { emoji: '🍔', keywords: ['hamburguer', 'hambúrguer', 'lanche', 'comida'] },
150
+ { emoji: '🍕', keywords: ['pizza', 'comida', 'lanche'] },
151
+ { emoji: '🍟', keywords: ['batata', 'frita', 'lanche'] },
152
+ { emoji: '🍿', keywords: ['pipoca', 'cinema', 'filme'] },
153
+ { emoji: '🍞', keywords: ['pao', 'pão', 'padaria'] },
154
+ { emoji: '🧀', keywords: ['queijo', 'comida'] },
155
+ { emoji: '🥗', keywords: ['salada', 'saudavel', 'saudável', 'comida'] },
156
+ { emoji: '☕', keywords: ['cafe', 'café', 'bebida', 'quente'] },
157
+ { emoji: '🍺', keywords: ['cerveja', 'bebida', 'chopp'] },
158
+ { emoji: '🍷', keywords: ['vinho', 'bebida', 'taca', 'taça'] },
159
+ { emoji: '🥂', keywords: ['brinde', 'comemorar', 'champanhe'] },
160
+ { emoji: '🥤', keywords: ['refrigerante', 'bebida', 'copo'] },
161
+ ],
162
+ },
163
+ ] as const
164
+
165
+ /**
166
+ * Índice achatado: a busca ignora categoria, porque quem digita "casa" quer o resultado venha de
167
+ * onde vier. A ordem preserva a das categorias, então o resultado sai agrupado por afinidade sem
168
+ * precisar ordenar.
169
+ */
170
+ const ALL_ENTRIES: readonly EmojiEntry[] = EMOJI_CATEGORIES.flatMap((category) => category.entries)
171
+
172
+ function normalize(value: string): string {
173
+ return value
174
+ .toLowerCase()
175
+ .normalize('NFD')
176
+ .replace(/\p{Diacritic}/gu, '')
177
+ .trim()
178
+ }
179
+
180
+ /**
181
+ * Busca por prefixo de palavra-chave, não por substring: "ca" traz "casa" e "cartão", mas "sa" não
182
+ * traz "casa" — casar no meio da palavra devolvia resultado que ninguém consegue explicar.
183
+ */
184
+ export function searchEmojis(query: string): readonly EmojiEntry[] {
185
+ const term = normalize(query)
186
+ if (!term) return ALL_ENTRIES
187
+
188
+ return ALL_ENTRIES.filter((entry) => entry.keywords.some((keyword) => normalize(keyword).startsWith(term)))
189
+ }
@@ -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[]>(
@@ -0,0 +1,56 @@
1
+ import { useMemo } from 'react'
2
+ import { useConversations } from '../providers/ConversationsProvider'
3
+ import type { ConversationTemplate } from '../providers/types'
4
+
5
+ export interface UseConversationActionsResult {
6
+ /** `undefined` quando a API do host não implementa a operação — a UI esconde a afordância. */
7
+ takeover: (() => Promise<void>) | undefined
8
+ release: (() => Promise<void>) | undefined
9
+ finalize: (() => Promise<void>) | undefined
10
+ }
11
+
12
+ /**
13
+ * Ações de atendimento de UMA conversa, já ligadas ao id.
14
+ *
15
+ * Separado de `useConversationMessages` porque assumir e devolver conversa também acontece a
16
+ * partir da lista, onde nenhuma thread está aberta — embutir nas mensagens obrigaria a carregar
17
+ * a thread inteira só para desenhar um botão na linha.
18
+ */
19
+ export function useConversationActions(conversationId: string): UseConversationActionsResult {
20
+ const context = useConversations()
21
+ if (!context) {
22
+ throw new Error('useConversationActions requires an ancestor <ConversationsProvider>')
23
+ }
24
+ const { api } = context
25
+
26
+ return useMemo(
27
+ () => ({
28
+ takeover: api.takeover ? () => api.takeover!(conversationId) : undefined,
29
+ release: api.release ? () => api.release!(conversationId) : undefined,
30
+ finalize: api.finalize ? () => api.finalize!(conversationId) : undefined,
31
+ }),
32
+ [api, conversationId],
33
+ )
34
+ }
35
+
36
+ export interface UseInboxActionsResult {
37
+ markAllRead: (() => Promise<void>) | undefined
38
+ listTemplates: (() => Promise<ConversationTemplate[]>) | undefined
39
+ }
40
+
41
+ /** Ações que valem para a caixa inteira, sem conversa selecionada. */
42
+ export function useInboxActions(): UseInboxActionsResult {
43
+ const context = useConversations()
44
+ if (!context) {
45
+ throw new Error('useInboxActions requires an ancestor <ConversationsProvider>')
46
+ }
47
+ const { api } = context
48
+
49
+ return useMemo(
50
+ () => ({
51
+ markAllRead: api.markAllRead ? () => api.markAllRead!() : undefined,
52
+ listTemplates: api.listTemplates ? () => api.listTemplates!() : undefined,
53
+ }),
54
+ [api],
55
+ )
56
+ }
@@ -1,21 +1,23 @@
1
1
  import { useConversations } from '../providers/ConversationsProvider'
2
2
  import { useAsyncResource } from './useAsyncResource'
3
- import type { ConversationDocument } from '../providers/types'
3
+ import { documentsOf, totalOf } from '../lib/paginated'
4
+ import type { ConversationDocument, ListDocumentsParams } from '../providers/types'
4
5
 
5
- export interface UseConversationDocumentsParams {
6
- search?: string
7
- page?: number
8
- }
6
+ export type UseConversationDocumentsParams = ListDocumentsParams
9
7
 
10
8
  export interface UseConversationDocumentsResult {
11
9
  documents: ConversationDocument[]
10
+ /** Total no servidor. Cai para o tamanho da página quando a API devolve só o array. */
11
+ total: number
12
12
  loading: boolean
13
13
  error: Error | undefined
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,9 +27,13 @@ export function useConversationDocuments(
25
27
  const { api } = context
26
28
 
27
29
  const { data, loading, error, refetch } = useAsyncResource(
28
- () => api.getDocuments(conversationId, params),
29
- [conversationId, params?.search, params?.page],
30
+ () => (conversationId ? api.getDocuments(conversationId, params) : Promise.resolve([])),
31
+ [conversationId, params?.search, params?.page, params?.limit, params?.source, params?.sortDirection],
30
32
  )
31
33
 
32
- return { documents: data ?? [], loading, error, refetch }
34
+ if (data === undefined) {
35
+ return { documents: [], total: 0, loading, error, refetch }
36
+ }
37
+
38
+ return { documents: documentsOf(data), total: totalOf(data), loading, error, refetch }
33
39
  }
@@ -1,16 +1,14 @@
1
1
  import { useConversations } from '../providers/ConversationsProvider'
2
2
  import { useAsyncResource } from './useAsyncResource'
3
- import type { ConversationSummary } from '../providers/types'
3
+ import { conversationsOf, totalOf } from '../lib/paginated'
4
+ import type { ConversationSummary, ListConversationsParams } from '../providers/types'
4
5
 
5
- export interface UseConversationListParams {
6
- page?: number
7
- limit?: number
8
- waitingHuman?: boolean
9
- search?: string
10
- }
6
+ export type UseConversationListParams = ListConversationsParams
11
7
 
12
8
  export interface UseConversationListResult {
13
9
  conversations: ConversationSummary[]
10
+ /** Total no servidor. Cai para o tamanho da página quando a API devolve só o array. */
11
+ total: number
14
12
  loading: boolean
15
13
  error: Error | undefined
16
14
  refetch: () => Promise<void>
@@ -23,10 +21,18 @@ export function useConversationList(params?: UseConversationListParams): UseConv
23
21
  }
24
22
  const { api } = context
25
23
 
24
+ // `filters` é objeto novo a cada render do host; serializar evita refetch em laço sem obrigar
25
+ // o consumidor a memoizar — omissão que só apareceria como loop de rede em produção.
26
+ const filtersKey = JSON.stringify(params?.filters ?? {})
27
+
26
28
  const { data, loading, error, refetch } = useAsyncResource(
27
29
  () => api.fetchConversations(params),
28
- [params?.page, params?.limit, params?.waitingHuman, params?.search],
30
+ [params?.page, params?.limit, params?.waitingHuman, params?.search, filtersKey],
29
31
  )
30
32
 
31
- return { conversations: data ?? [], loading, error, refetch }
33
+ if (data === undefined) {
34
+ return { conversations: [], total: 0, loading, error, refetch }
35
+ }
36
+
37
+ return { conversations: conversationsOf(data), total: totalOf(data), loading, error, refetch }
32
38
  }
@@ -10,7 +10,7 @@ export interface UseConversationMessagesResult {
10
10
  refetch: () => Promise<void>
11
11
  sendMessage: (text: string) => Promise<MessagePayload>
12
12
  sendMedia: (data: { base64: string; mimeType: string; filename: string; caption?: string }) => Promise<MessagePayload>
13
- sendTemplate: (data: { templateName: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
13
+ sendTemplate: (data: { templateName?: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
14
14
  markRead: () => Promise<void>
15
15
  }
16
16
 
@@ -51,7 +51,7 @@ export function useConversationMessages(
51
51
  )
52
52
 
53
53
  const sendTemplate = useCallback(
54
- async (templateData: { templateName: string; languageCode?: string; bodyParams?: string[] }) => {
54
+ async (templateData: { templateName?: string; languageCode?: string; bodyParams?: string[] }) => {
55
55
  await api.sendTemplate(conversationId, templateData)
56
56
  await refetch()
57
57
  },