@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.21
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-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-TV4OQRGH.js +2187 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +862 -136
- package/dist/index.js +1746 -1124
- package/dist/preview/index.d.ts +470 -0
- package/dist/preview/index.js +1329 -0
- package/dist/styles.css +228 -0
- package/dist/types-C6A_9edv.d.ts +456 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +280 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +44 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +96 -22
- package/src/MessageBubble.tsx +77 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +165 -19
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +380 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +69 -7
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +122 -0
- package/src/conversationTranscript.ts +89 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +17 -14
- package/src/flows/FlowMapNode.tsx +3 -1
- package/src/flows/FlowNodeCard.tsx +22 -4
- package/src/flows/FlowNodePanel.tsx +132 -35
- package/src/flows/FlowPalette.tsx +6 -2
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/flowGraph.ts +5 -5
- package/src/flows/labels.ts +5 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +140 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +334 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +194 -0
- package/src/preview/createPreviewWebhookClient.ts +222 -0
- package/src/preview/index.ts +67 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +175 -12
- package/src/quickReply.test.ts +58 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +173 -0
- package/src/types.ts +72 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- 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,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda o rótulo de tipo da bolha de documento.
|
|
3
|
+
*
|
|
4
|
+
* O defeito que motivou: `mimeType.split('/')[1].toUpperCase()` transformava um `.docx` em
|
|
5
|
+
* `VND.OPENXMLFORMATS-OFFICEDOCUMENT.WORDPROCESSINGML.DOCUMENT` — 58 caracteres numa linha que não
|
|
6
|
+
* quebra, esticando a bolha até empurrar a coluna da conversa e estourar o layout de três painéis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'bun:test'
|
|
10
|
+
|
|
11
|
+
import { documentTypeLabel } from './MediaRenderer'
|
|
12
|
+
|
|
13
|
+
describe('documentTypeLabel', () => {
|
|
14
|
+
// O caso que quebrou a tela.
|
|
15
|
+
it('usa a extensão do arquivo em vez do subtipo gigante do Office', () => {
|
|
16
|
+
const rotulo = documentTypeLabel(
|
|
17
|
+
'contrato.docx',
|
|
18
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
expect(rotulo).toBe('DOCX')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('serve para os tipos curtos também', () => {
|
|
25
|
+
expect(documentTypeLabel('nota-fiscal.pdf', 'application/pdf')).toBe('PDF')
|
|
26
|
+
expect(documentTypeLabel('lista.txt', 'text/plain')).toBe('TXT')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Áudio e sticker chegam sem nome de arquivo: o rótulo vem do mimeType, ainda curto.
|
|
30
|
+
it('cai no sufixo do subtipo quando não há nome', () => {
|
|
31
|
+
expect(documentTypeLabel(undefined, 'application/vnd.ms-excel')).toBe('MS-EXCEL')
|
|
32
|
+
expect(documentTypeLabel(undefined, 'application/pdf')).toBe('PDF')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('descarta parâmetro do mimeType', () => {
|
|
36
|
+
expect(documentTypeLabel(undefined, 'audio/ogg; codecs=opus')).toBe('OGG')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Nome sem ponto não tem extensão — o id da mídia, por exemplo.
|
|
40
|
+
it('não confunde nome sem extensão com extensão', () => {
|
|
41
|
+
expect(documentTypeLabel('seed-media-03', 'application/zip')).toBe('ZIP')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('tem rótulo para o caso sem nome e sem tipo', () => {
|
|
45
|
+
expect(documentTypeLabel()).toBe('FILE')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Teto de tamanho: nenhum rótulo pode voltar a esticar a bolha.
|
|
49
|
+
it('nunca passa de 12 caracteres', () => {
|
|
50
|
+
const longo = documentTypeLabel(
|
|
51
|
+
undefined,
|
|
52
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(longo.length).toBeLessThanOrEqual(12)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
|
26
|
+
const isDark = useIsDarkTheme()
|
|
27
27
|
const positions = useMemo(() => computeFlowMapLayout(graphs, rootKey), [graphs, rootKey])
|
|
28
28
|
|
|
29
29
|
const nodes = useMemo<Node[]>(
|
|
30
|
-
() =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 &&
|
|
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 {
|
|
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
|
|
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 } =
|
|
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 =
|
|
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')
|