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

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 (99) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-OIDAIVCH.js +1985 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +729 -136
  6. package/dist/index.js +1418 -1104
  7. package/dist/preview/index.d.ts +373 -0
  8. package/dist/preview/index.js +1247 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-O7kMP1Yn.d.ts +416 -0
  11. package/package.json +10 -3
  12. package/src/AudioRecorderButton.test.tsx +30 -0
  13. package/src/AudioRecorderButton.tsx +248 -0
  14. package/src/Avatar.tsx +30 -4
  15. package/src/ChannelIcon.tsx +87 -0
  16. package/src/ConversationContextPanel.tsx +127 -0
  17. package/src/ConversationDocumentsPanel.tsx +425 -0
  18. package/src/ConversationHeader.tsx +257 -0
  19. package/src/ConversationListItem.tsx +54 -7
  20. package/src/ConversationLocalesProvider.tsx +16 -0
  21. package/src/ConversationRow.tsx +137 -0
  22. package/src/DateDivider.tsx +16 -3
  23. package/src/DocumentsLibrary.tsx +322 -0
  24. package/src/EmojiPicker.tsx +69 -55
  25. package/src/FileIcon.test.ts +83 -0
  26. package/src/FileIcon.tsx +88 -11
  27. package/src/InteractiveMessage.test.tsx +41 -0
  28. package/src/InteractiveMessage.tsx +143 -0
  29. package/src/Lightbox.tsx +18 -3
  30. package/src/MediaRenderer.tsx +64 -21
  31. package/src/MessageBubble.tsx +54 -5
  32. package/src/MessageComposer.test.tsx +35 -0
  33. package/src/MessageComposer.tsx +146 -19
  34. package/src/RichMessageComposer.test.tsx +83 -0
  35. package/src/RichMessageComposer.tsx +380 -0
  36. package/src/Wallpaper.test.tsx +21 -0
  37. package/src/Wallpaper.tsx +54 -6
  38. package/src/WhatsAppMessageEditor.tsx +28 -4
  39. package/src/WindowExpiredNotice.tsx +57 -0
  40. package/src/audioRecorderFormat.test.ts +67 -0
  41. package/src/conversationChannel.test.ts +53 -0
  42. package/src/conversationChannel.ts +146 -0
  43. package/src/conversationTranscript.test.ts +65 -0
  44. package/src/conversationTranscript.ts +64 -0
  45. package/src/conversationWindow.test.ts +90 -0
  46. package/src/conversationWindow.ts +78 -0
  47. package/src/documentTypeLabel.test.ts +57 -0
  48. package/src/emojiCatalog.test.ts +35 -0
  49. package/src/emojiCatalog.ts +189 -0
  50. package/src/flows/FlowGroupHeader.tsx +12 -2
  51. package/src/flows/FlowMapCanvas.tsx +17 -14
  52. package/src/flows/FlowMapNode.tsx +3 -1
  53. package/src/flows/FlowNodeCard.tsx +22 -4
  54. package/src/flows/FlowNodePanel.tsx +132 -35
  55. package/src/flows/FlowPalette.tsx +6 -2
  56. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  57. package/src/flows/flowGraph.ts +5 -5
  58. package/src/flows/labels.ts +5 -0
  59. package/src/hooks/useConversationActions.ts +56 -0
  60. package/src/hooks/useConversationDocuments.ts +15 -9
  61. package/src/hooks/useConversationList.ts +15 -9
  62. package/src/hooks/useConversationMessages.ts +2 -2
  63. package/src/index.ts +129 -16
  64. package/src/lib/cn.test.ts +29 -0
  65. package/src/lib/cn.ts +15 -0
  66. package/src/lib/createMediaUrlResolver.ts +33 -0
  67. package/src/lib/paginated.test.ts +33 -0
  68. package/src/lib/paginated.ts +26 -0
  69. package/src/lib/phone.ts +34 -0
  70. package/src/preview/ConversationPreview.tsx +325 -0
  71. package/src/preview/MediaTypesPreview.tsx +87 -0
  72. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  73. package/src/preview/createMockConversationsApi.ts +271 -0
  74. package/src/preview/createMockSSEProvider.ts +40 -0
  75. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  76. package/src/preview/createPreviewBridgeClient.ts +88 -0
  77. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  78. package/src/preview/createPreviewWebhookClient.ts +131 -0
  79. package/src/preview/index.ts +61 -0
  80. package/src/preview/mediaTypeOf.test.ts +15 -0
  81. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  82. package/src/preview/mockEventSource.ts +53 -0
  83. package/src/preview/preview.test.ts +177 -0
  84. package/src/preview/previewFileSamples.test.ts +151 -0
  85. package/src/preview/previewFileSamples.ts +74 -0
  86. package/src/preview/previewFixtures.ts +440 -0
  87. package/src/preview/previewMediaSource.test.ts +62 -0
  88. package/src/preview/previewMediaSource.ts +91 -0
  89. package/src/preview/previewStore.ts +193 -0
  90. package/src/preview/startPreviewScript.ts +60 -0
  91. package/src/providers/types.ts +163 -11
  92. package/src/quickReply.test.ts +58 -0
  93. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  94. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  95. package/src/styles.css +136 -0
  96. package/src/types.ts +46 -1
  97. package/src/useDarkMode.ts +26 -0
  98. package/src/useIsNarrow.ts +29 -0
  99. package/src/useWaitingNotifications.ts +74 -29
@@ -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
+ }
@@ -18,10 +18,20 @@ export function FlowGroupHeader({ data }: NodeProps) {
18
18
  return (
19
19
  <div className="flex items-center gap-2 rounded-full border border-cyan-300 dark:border-cyan-700 bg-cyan-50 dark:bg-cyan-950/50 px-3 py-1 text-xs font-medium text-cyan-800 dark:text-cyan-200 shadow-sm whitespace-nowrap">
20
20
  <span>{label}</span>
21
- <button type="button" onClick={onFocus} title={labels.flowGroup.focus} className="hover:text-blue-600 dark:hover:text-blue-400">
21
+ <button
22
+ type="button"
23
+ onClick={onFocus}
24
+ title={labels.flowGroup.focus}
25
+ className="hover:text-blue-600 dark:hover:text-blue-400"
26
+ >
22
27
  <Maximize2 size={12} />
23
28
  </button>
24
- <button type="button" onClick={onClose} title={labels.flowGroup.close} className="hover:text-red-600 dark:hover:text-red-400">
29
+ <button
30
+ type="button"
31
+ onClick={onClose}
32
+ title={labels.flowGroup.close}
33
+ className="hover:text-red-600 dark:hover:text-red-400"
34
+ >
25
35
  <X size={12} />
26
36
  </button>
27
37
  </div>
@@ -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,22 +23,25 @@ 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[]>(
30
- () => Object.values(graphs).map((g): Node => ({
31
- id: g.key,
32
- type: 'flowMapNode',
33
- position: positions[g.key] ?? { x: 0, y: 0 },
34
- data: {
35
- label: g.label,
36
- nodeCount: Object.keys(g.nodes).length,
37
- isRoot: g.key === rootKey,
38
- labels,
39
- onOpen: () => onOpenFlow(g.key),
40
- } satisfies FlowMapNodeData,
41
- })),
30
+ () =>
31
+ Object.values(graphs).map(
32
+ (g): Node => ({
33
+ id: g.key,
34
+ type: 'flowMapNode',
35
+ position: positions[g.key] ?? { x: 0, y: 0 },
36
+ data: {
37
+ label: g.label,
38
+ nodeCount: Object.keys(g.nodes).length,
39
+ isRoot: g.key === rootKey,
40
+ labels,
41
+ onOpen: () => onOpenFlow(g.key),
42
+ } satisfies FlowMapNodeData,
43
+ }),
44
+ ),
42
45
  [graphs, positions, rootKey, onOpenFlow, labels],
43
46
  )
44
47
 
@@ -21,7 +21,9 @@ export function FlowMapNode({ data }: NodeProps) {
21
21
  <div className="flex items-center gap-1.5 text-emerald-700 dark:text-emerald-300">
22
22
  <GitBranch size={14} />
23
23
  <span className="text-sm font-semibold truncate">{label}</span>
24
- {isRoot && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" title={labels.startNodeTooltip} />}
24
+ {isRoot && (
25
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" title={labels.startNodeTooltip} />
26
+ )}
25
27
  </div>
26
28
  <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{labels.flowMap.nodeCount(nodeCount)}</p>
27
29
  <button
@@ -1,5 +1,17 @@
1
1
  import { Handle, Position, type NodeProps } from '@xyflow/react'
2
- import { MessageCircleQuestion, GitBranch, Zap, ListTree, Diamond, AlertTriangle, AlertCircle, Headset, Clock3, ShoppingBag, type LucideIcon } from 'lucide-react'
2
+ import {
3
+ MessageCircleQuestion,
4
+ GitBranch,
5
+ Zap,
6
+ ListTree,
7
+ Diamond,
8
+ AlertTriangle,
9
+ AlertCircle,
10
+ Headset,
11
+ Clock3,
12
+ ShoppingBag,
13
+ type LucideIcon,
14
+ } from 'lucide-react'
3
15
  import type { FlowEditorLabels } from './labels'
4
16
  import type { FlowNodeData, GraphIssue } from './flowGraph'
5
17
 
@@ -83,7 +95,9 @@ function sourceRows(node: FlowNodeData, labels: FlowEditorLabels): { id: string;
83
95
  function SourceRow({ label, isDefault, handleId }: { label: string; isDefault: boolean; handleId: string }) {
84
96
  return (
85
97
  <div className="relative flex items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-600 bg-white/70 dark:bg-gray-900/40 px-2 py-1 pr-3">
86
- <span className={`text-xs truncate flex-1 ${isDefault ? 'italic text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}>
98
+ <span
99
+ className={`text-xs truncate flex-1 ${isDefault ? 'italic text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}
100
+ >
87
101
  {label}
88
102
  </span>
89
103
  <Handle
@@ -98,10 +112,14 @@ function SourceRow({ label, isDefault, handleId }: { label: string; isDefault: b
98
112
  }
99
113
 
100
114
  export function FlowNodeCard({ data }: NodeProps) {
101
- const { node, liveCount, isStart, isSelected, issues, labels, actionKindIcons, onSelect } = data as unknown as FlowNodeCardData
115
+ const { node, liveCount, isStart, isSelected, issues, labels, actionKindIcons, onSelect } =
116
+ data as unknown as FlowNodeCardData
102
117
  const label = nodeLabel(node, labels)
103
118
  const iconMap = { ...DEFAULT_ACTION_KIND_ICON, ...actionKindIcons }
104
- const Icon = node.type === 'action' && node.actionKind ? (iconMap[node.actionKind] ?? NODE_TYPE_ICON[node.type]) : NODE_TYPE_ICON[node.type]
119
+ const Icon =
120
+ node.type === 'action' && node.actionKind
121
+ ? (iconMap[node.actionKind] ?? NODE_TYPE_ICON[node.type])
122
+ : NODE_TYPE_ICON[node.type]
105
123
  const rows = sourceRows(node, labels)
106
124
  const hasError = issues.some((i) => i.severity === 'error')
107
125
  const hasWarning = !hasError && issues.some((i) => i.severity === 'warning')
@@ -1,8 +1,8 @@
1
- import { useState } from 'react'
1
+ import { useState, type ReactNode } from 'react'
2
2
  import { Plus, Trash2, Save, X, AlertTriangle, AlertCircle } from 'lucide-react'
3
3
  import { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
4
4
  import { nodeLabel } from './FlowNodeCard'
5
- import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS } from './flowGraph'
5
+ import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS, BUILT_IN_ACTION_KINDS } from './flowGraph'
6
6
  import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
7
7
  import type { FlowGraphData, FlowNodeData, GraphIssue } from './flowGraph'
8
8
 
@@ -73,6 +73,14 @@ export interface FlowNodePanelProps {
73
73
  onChange: (updated: FlowNodeData) => void
74
74
  onDelete: (nodeId: string) => void
75
75
  labels?: Partial<FlowEditorLabels>
76
+ /**
77
+ * Seletor de arquivos do nó `send_media`, renderizado no lugar da mensagem direta.
78
+ *
79
+ * Slot, e não uma lista de arquivos por prop, porque a biblioteca é do host: upload, permissão e
80
+ * URL assinada são dele, e o painel não tem como buscar nada. Ausente, o nó continua editável —
81
+ * só não dá para anexar por aqui.
82
+ */
83
+ renderMediaPicker?: (node: FlowNodeData) => ReactNode
76
84
  }
77
85
 
78
86
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowNodePanel.tsx —
@@ -86,18 +94,26 @@ export function FlowNodePanel({
86
94
  onChange,
87
95
  onDelete,
88
96
  labels: labelsOverride,
97
+ renderMediaPicker,
89
98
  }: FlowNodePanelProps) {
90
99
  const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
91
100
  const [draft, setDraft] = useState<FlowNodeData>(node)
92
101
  const otherNodeIds = Object.keys(graph.nodes).filter((id) => id !== node.id)
93
102
  const isFixedLogic = draft.type === 'entrada_choice'
94
103
  const isAction = draft.type === 'action'
104
+ const isSendMedia = isAction && draft.actionKind === BUILT_IN_ACTION_KINDS.SEND_MEDIA
95
105
  const isCondition = draft.type === 'condition'
96
106
  const isStart = graph.startNodeId === node.id
97
107
  const nodeIssues = issues.filter((i) => i.nodeId === node.id)
98
108
  // Chaves já usadas por perguntas deste fluxo — sugestão pro campo de variável da condição,
99
109
  // sem travar em texto livre (a variável pode ter vindo de outro fluxo ou de um cálculo derivado).
100
- const knownContextKeys = [...new Set(Object.values(graph.nodes).map((n) => n.contextKey).filter((key): key is string => !!key))]
110
+ const knownContextKeys = [
111
+ ...new Set(
112
+ Object.values(graph.nodes)
113
+ .map((n) => n.contextKey)
114
+ .filter((key): key is string => !!key),
115
+ ),
116
+ ]
101
117
  const conditionAnswerIds = isCondition ? ['true', 'false'] : (draft.options ?? []).map(([id]) => id)
102
118
 
103
119
  function updateNextString(value: string) {
@@ -106,7 +122,8 @@ export function FlowNodePanel({
106
122
 
107
123
  function updateNextByAnswer(answerId: string, value: string) {
108
124
  setDraft((prev) => {
109
- const current = typeof prev.next === 'object' && prev.next ? prev.next : { byAnswer: {}, default: otherNodeIds[0] ?? '' }
125
+ const current =
126
+ typeof prev.next === 'object' && prev.next ? prev.next : { byAnswer: {}, default: otherNodeIds[0] ?? '' }
110
127
  return { ...prev, next: { ...current, byAnswer: { ...current.byAnswer, [answerId]: value } } }
111
128
  })
112
129
  }
@@ -129,7 +146,10 @@ export function FlowNodePanel({
129
146
  }
130
147
 
131
148
  function addOption() {
132
- setDraft((prev) => ({ ...prev, options: [...(prev.options ?? []), [String((prev.options?.length ?? 0) + 1), 'Nova opção']] }))
149
+ setDraft((prev) => ({
150
+ ...prev,
151
+ options: [...(prev.options ?? []), [String((prev.options?.length ?? 0) + 1), 'Nova opção']],
152
+ }))
133
153
  }
134
154
 
135
155
  function removeOption(index: number) {
@@ -140,12 +160,16 @@ export function FlowNodePanel({
140
160
  return (
141
161
  <>
142
162
  {otherNodeIds.map((id) => (
143
- <option key={id} value={id}>{truncateLabel(nodeLabel(graph.nodes[id], labels))}</option>
163
+ <option key={id} value={id}>
164
+ {truncateLabel(nodeLabel(graph.nodes[id], labels))}
165
+ </option>
144
166
  ))}
145
167
  {otherFlows.length > 0 && (
146
168
  <optgroup label={labels.nodePanel.otherFlowsGroup}>
147
169
  {otherFlows.map((flow) => (
148
- <option key={flow.key} value={`${CROSS_FLOW_PREFIX}${flow.key}`}>{flow.label}</option>
170
+ <option key={flow.key} value={`${CROSS_FLOW_PREFIX}${flow.key}`}>
171
+ {flow.label}
172
+ </option>
149
173
  ))}
150
174
  </optgroup>
151
175
  )}
@@ -157,13 +181,17 @@ export function FlowNodePanel({
157
181
  <div className="fixed inset-y-0 right-0 w-96 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-xl z-50 flex flex-col">
158
182
  <div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
159
183
  <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{labels.nodePanel.title}</h3>
160
- <button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
184
+ <button onClick={onClose} className="text-gray-400 hover:text-gray-600">
185
+ <X size={18} />
186
+ </button>
161
187
  </div>
162
188
 
163
189
  <div className="flex-1 overflow-y-auto p-4 space-y-4">
164
190
  {nodeIssues.length > 0 && (
165
191
  <div className="rounded-lg border border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40 p-2.5 space-y-1.5">
166
- {nodeIssues.map((issue, i) => <IssueRow key={i} issue={issue} />)}
192
+ {nodeIssues.map((issue, i) => (
193
+ <IssueRow key={i} issue={issue} />
194
+ ))}
167
195
  </div>
168
196
  )}
169
197
 
@@ -185,21 +213,33 @@ export function FlowNodePanel({
185
213
 
186
214
  {draft.contextKey && (
187
215
  <div>
188
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.contextKey}</label>
189
- <input value={draft.contextKey} disabled className="w-full mt-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2.5 text-sm text-gray-500" />
216
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
217
+ {labels.nodePanel.contextKey}
218
+ </label>
219
+ <input
220
+ value={draft.contextKey}
221
+ disabled
222
+ className="w-full mt-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2.5 text-sm text-gray-500"
223
+ />
190
224
  </div>
191
225
  )}
192
226
 
193
227
  {!isFixedLogic && !isAction && !isCondition && (
194
228
  <div>
195
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.questionType}</label>
229
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
230
+ {labels.nodePanel.questionType}
231
+ </label>
196
232
  <select
197
233
  value={draft.questionType ?? 'text'}
198
- onChange={(e) => setDraft((prev) => ({ ...prev, questionType: e.target.value as FlowNodeData['questionType'] }))}
234
+ onChange={(e) =>
235
+ setDraft((prev) => ({ ...prev, questionType: e.target.value as FlowNodeData['questionType'] }))
236
+ }
199
237
  className={`w-full mt-1 ${SELECT_CLASSNAME}`}
200
238
  >
201
239
  {Object.entries(labels.questionTypeLabels).map(([key, label]) => (
202
- <option key={key} value={key}>{label}</option>
240
+ <option key={key} value={key}>
241
+ {label}
242
+ </option>
203
243
  ))}
204
244
  </select>
205
245
  </div>
@@ -218,7 +258,9 @@ export function FlowNodePanel({
218
258
  {isCondition && (
219
259
  <div className="space-y-3">
220
260
  <div>
221
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionVariable}</label>
261
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
262
+ {labels.nodePanel.conditionVariable}
263
+ </label>
222
264
  <input
223
265
  value={draft.conditionContextKey ?? ''}
224
266
  onChange={(e) => setDraft((prev) => ({ ...prev, conditionContextKey: e.target.value }))}
@@ -226,23 +268,36 @@ export function FlowNodePanel({
226
268
  className={`w-full mt-1 ${INPUT_CLASSNAME}`}
227
269
  />
228
270
  <datalist id="condition-context-keys">
229
- {knownContextKeys.map((key) => <option key={key} value={key} />)}
271
+ {knownContextKeys.map((key) => (
272
+ <option key={key} value={key} />
273
+ ))}
230
274
  </datalist>
231
275
  </div>
232
276
  <div>
233
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionOperator}</label>
277
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
278
+ {labels.nodePanel.conditionOperator}
279
+ </label>
234
280
  <select
235
281
  value={draft.conditionOperator ?? '>'}
236
- onChange={(e) => setDraft((prev) => ({ ...prev, conditionOperator: e.target.value as FlowNodeData['conditionOperator'] }))}
282
+ onChange={(e) =>
283
+ setDraft((prev) => ({
284
+ ...prev,
285
+ conditionOperator: e.target.value as FlowNodeData['conditionOperator'],
286
+ }))
287
+ }
237
288
  className={`w-full mt-1 ${SELECT_CLASSNAME}`}
238
289
  >
239
290
  {CONDITION_OPERATORS.map((operator) => (
240
- <option key={operator} value={operator}>{labels.conditionOperatorLabels[operator] ?? operator}</option>
291
+ <option key={operator} value={operator}>
292
+ {labels.conditionOperatorLabels[operator] ?? operator}
293
+ </option>
241
294
  ))}
242
295
  </select>
243
296
  </div>
244
297
  <div>
245
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionValue}</label>
298
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
299
+ {labels.nodePanel.conditionValue}
300
+ </label>
246
301
  <input
247
302
  value={draft.conditionValue ?? ''}
248
303
  onChange={(e) => setDraft((prev) => ({ ...prev, conditionValue: e.target.value }))}
@@ -262,7 +317,21 @@ export function FlowNodePanel({
262
317
  />
263
318
  )}
264
319
 
265
- {isAction && draft.actionKind !== 'send_product_list' && (
320
+ {/* Os arquivos moram na biblioteca, não no grafo — trocar o material não repassa pelo editor. */}
321
+ {isSendMedia && (
322
+ <div>
323
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.media}</label>
324
+ <div className="mt-1">
325
+ {renderMediaPicker?.(node) ?? (
326
+ <p className="text-xs text-gray-400">{labels.nodePanel.mediaUnavailable}</p>
327
+ )}
328
+ </div>
329
+ </div>
330
+ )}
331
+
332
+ {/* `send_media` fica de fora: o handler do módulo só envia os anexos, então um campo de
333
+ mensagem aqui seria texto que o cliente nunca recebe. A legenda é por arquivo. */}
334
+ {isAction && draft.actionKind !== 'send_product_list' && !isSendMedia && (
266
335
  <WhatsAppTextField
267
336
  label={labels.nodePanel.directMessage}
268
337
  value={draft.directMessage ?? ''}
@@ -283,11 +352,21 @@ export function FlowNodePanel({
283
352
  <div className="space-y-2">
284
353
  {(draft.options ?? []).map(([id, label], i) => (
285
354
  <div key={i} className="flex items-center gap-2">
286
- <input value={id} onChange={(e) => updateOption(i, 0, e.target.value)} placeholder={labels.nodePanel.optionId}
287
- className={`w-16 ${INPUT_CLASSNAME}`} />
288
- <input value={label} onChange={(e) => updateOption(i, 1, e.target.value)} placeholder={labels.nodePanel.optionLabel}
289
- className={`flex-1 ${INPUT_CLASSNAME}`} />
290
- <button onClick={() => removeOption(i)} className="text-gray-400 hover:text-red-600"><Trash2 size={14} /></button>
355
+ <input
356
+ value={id}
357
+ onChange={(e) => updateOption(i, 0, e.target.value)}
358
+ placeholder={labels.nodePanel.optionId}
359
+ className={`w-16 ${INPUT_CLASSNAME}`}
360
+ />
361
+ <input
362
+ value={label}
363
+ onChange={(e) => updateOption(i, 1, e.target.value)}
364
+ placeholder={labels.nodePanel.optionLabel}
365
+ className={`flex-1 ${INPUT_CLASSNAME}`}
366
+ />
367
+ <button onClick={() => removeOption(i)} className="text-gray-400 hover:text-red-600">
368
+ <Trash2 size={14} />
369
+ </button>
291
370
  </div>
292
371
  ))}
293
372
  </div>
@@ -299,8 +378,11 @@ export function FlowNodePanel({
299
378
  <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.next}</label>
300
379
  <p className="text-[11px] text-gray-400 dark:text-gray-500 mb-1">{labels.nodePanel.nextHint}</p>
301
380
  {typeof draft.next !== 'object' && !isCondition ? (
302
- <select value={typeof draft.next === 'string' ? draft.next : ''} onChange={(e) => updateNextString(e.target.value)}
303
- className={`w-full mt-1 ${SELECT_CLASSNAME}`}>
381
+ <select
382
+ value={typeof draft.next === 'string' ? draft.next : ''}
383
+ onChange={(e) => updateNextString(e.target.value)}
384
+ className={`w-full mt-1 ${SELECT_CLASSNAME}`}
385
+ >
304
386
  <option value="">—</option>
305
387
  {nextNodeOptions()}
306
388
  </select>
@@ -309,10 +391,17 @@ export function FlowNodePanel({
309
391
  {conditionAnswerIds.map((id) => (
310
392
  <div key={id} className="flex items-center gap-2">
311
393
  <span className="text-xs text-gray-500 w-32 shrink-0">
312
- {isCondition ? (id === 'true' ? labels.nodePanel.conditionTrue : labels.nodePanel.conditionFalse) : labels.nodePanel.nextByAnswer(id)}
394
+ {isCondition
395
+ ? id === 'true'
396
+ ? labels.nodePanel.conditionTrue
397
+ : labels.nodePanel.conditionFalse
398
+ : labels.nodePanel.nextByAnswer(id)}
313
399
  </span>
314
- <select value={draft.next && typeof draft.next === 'object' ? draft.next.byAnswer[id] ?? '' : ''} onChange={(e) => updateNextByAnswer(id, e.target.value)}
315
- className={`flex-1 ${SELECT_CLASSNAME}`}>
400
+ <select
401
+ value={draft.next && typeof draft.next === 'object' ? (draft.next.byAnswer[id] ?? '') : ''}
402
+ onChange={(e) => updateNextByAnswer(id, e.target.value)}
403
+ className={`flex-1 ${SELECT_CLASSNAME}`}
404
+ >
316
405
  <option value="">—</option>
317
406
  {nextNodeOptions()}
318
407
  </select>
@@ -322,8 +411,11 @@ export function FlowNodePanel({
322
411
  <span className="text-xs text-gray-500 w-32 shrink-0">
323
412
  {isCondition ? labels.nodePanel.conditionVariableMissing : labels.nodePanel.nextDefault}
324
413
  </span>
325
- <select value={draft.next && typeof draft.next === 'object' ? draft.next.default : ''} onChange={(e) => updateNextDefault(e.target.value)}
326
- className={`flex-1 ${SELECT_CLASSNAME}`}>
414
+ <select
415
+ value={draft.next && typeof draft.next === 'object' ? draft.next.default : ''}
416
+ onChange={(e) => updateNextDefault(e.target.value)}
417
+ className={`flex-1 ${SELECT_CLASSNAME}`}
418
+ >
327
419
  <option value="">—</option>
328
420
  {nextNodeOptions()}
329
421
  </select>
@@ -335,12 +427,17 @@ export function FlowNodePanel({
335
427
  </div>
336
428
 
337
429
  <div className="p-4 border-t border-gray-100 dark:border-gray-700 flex gap-2">
338
- <button onClick={() => onChange(draft)} className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
430
+ <button
431
+ onClick={() => onChange(draft)}
432
+ className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
433
+ >
339
434
  <Save size={14} /> {labels.nodePanel.save}
340
435
  </button>
341
436
  {!isStart && (
342
437
  <button
343
- onClick={() => { if (window.confirm(labels.nodePanel.deleteConfirm)) onDelete(node.id) }}
438
+ onClick={() => {
439
+ if (window.confirm(labels.nodePanel.deleteConfirm)) onDelete(node.id)
440
+ }}
344
441
  title={labels.nodePanel.delete}
345
442
  className="px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
346
443
  >