@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,106 @@
1
+ /**
2
+ * Configurações de template do WhatsApp, com submenu próprio.
3
+ *
4
+ * Junta as duas telas que já existiam soltas — escolher o template de reengajamento e criar um novo
5
+ * para aprovação da Meta — porque na cabeça de quem opera é um só assunto. Cada host montava esse
6
+ * agrupamento por conta, e é justamente o tipo de composição que deve nascer no pacote.
7
+ *
8
+ * Segue presentacional: nenhuma chamada de rede aqui. Templates, estado e handlers vêm por props.
9
+ */
10
+
11
+ import { useState, type FormEvent } from 'react'
12
+ import {
13
+ WhatsAppTemplateSettingsForm,
14
+ type WhatsAppTemplateSummary,
15
+ type WhatsAppTemplateVariableSuggestion,
16
+ } from './WhatsAppTemplateSettingsForm'
17
+ import {
18
+ WhatsAppCreateTemplateForm,
19
+ type WhatsAppCreateTemplateResult,
20
+ type WhatsAppCreateTemplateState,
21
+ } from './WhatsAppCreateTemplateForm'
22
+
23
+ export const TEMPLATE_SETTINGS_TAB = {
24
+ SELECT: 'select',
25
+ CREATE: 'create',
26
+ } as const
27
+ export type TemplateSettingsTab = (typeof TEMPLATE_SETTINGS_TAB)[keyof typeof TEMPLATE_SETTINGS_TAB]
28
+
29
+ export interface WhatsAppTemplatesSettingsLabels {
30
+ selectTab: string
31
+ createTab: string
32
+ }
33
+
34
+ export const DEFAULT_TEMPLATES_SETTINGS_LABELS: WhatsAppTemplatesSettingsLabels = {
35
+ selectTab: 'Template de reengajamento',
36
+ createTab: 'Criar template',
37
+ }
38
+
39
+ export interface WhatsAppTemplatesSettingsProps {
40
+ templates: WhatsAppTemplateSummary[]
41
+ loadingTemplates?: boolean
42
+ templatesError?: boolean
43
+ onRefreshTemplates?: () => void
44
+ selectedTemplateName: string
45
+ onSelectTemplate: (name: string, template: WhatsAppTemplateSummary | undefined) => void
46
+ variables: string[]
47
+ onVariablesChange: (variables: string[]) => void
48
+ availableVariables?: WhatsAppTemplateVariableSuggestion[]
49
+ saving?: boolean
50
+ saveSuccess?: boolean
51
+ onSave: (event: FormEvent) => void
52
+ /** Ausente = host não sabe criar template; a aba de criação nem aparece. */
53
+ create?: {
54
+ value: WhatsAppCreateTemplateState
55
+ onChange: (value: WhatsAppCreateTemplateState) => void
56
+ onSubmit: (event: FormEvent) => void
57
+ submitting?: boolean
58
+ result?: WhatsAppCreateTemplateResult | null
59
+ }
60
+ labels?: Partial<WhatsAppTemplatesSettingsLabels>
61
+ }
62
+
63
+ export function WhatsAppTemplatesSettings({
64
+ labels: labelsOverride,
65
+ create,
66
+ ...settingsProps
67
+ }: WhatsAppTemplatesSettingsProps) {
68
+ const labels = { ...DEFAULT_TEMPLATES_SETTINGS_LABELS, ...labelsOverride }
69
+ const [tab, setTab] = useState<TemplateSettingsTab>(TEMPLATE_SETTINGS_TAB.SELECT)
70
+
71
+ return (
72
+ <div className="space-y-4">
73
+ <nav className="flex gap-1 border-b" role="tablist">
74
+ <button
75
+ type="button"
76
+ role="tab"
77
+ aria-selected={tab === TEMPLATE_SETTINGS_TAB.SELECT}
78
+ onClick={() => setTab(TEMPLATE_SETTINGS_TAB.SELECT)}
79
+ className={`cv-subtab ${tab === TEMPLATE_SETTINGS_TAB.SELECT ? 'cv-subtab--active' : ''}`}
80
+ >
81
+ {labels.selectTab}
82
+ </button>
83
+
84
+ {/* A aba de criação só existe se o host souber criar: sem handler, ela seria um formulário
85
+ que não leva a nada. */}
86
+ {create ? (
87
+ <button
88
+ type="button"
89
+ role="tab"
90
+ aria-selected={tab === TEMPLATE_SETTINGS_TAB.CREATE}
91
+ onClick={() => setTab(TEMPLATE_SETTINGS_TAB.CREATE)}
92
+ className={`cv-subtab ${tab === TEMPLATE_SETTINGS_TAB.CREATE ? 'cv-subtab--active' : ''}`}
93
+ >
94
+ {labels.createTab}
95
+ </button>
96
+ ) : null}
97
+ </nav>
98
+
99
+ {tab === TEMPLATE_SETTINGS_TAB.SELECT ? (
100
+ <WhatsAppTemplateSettingsForm {...settingsProps} />
101
+ ) : create ? (
102
+ <WhatsAppCreateTemplateForm {...create} />
103
+ ) : null}
104
+ </div>
105
+ )
106
+ }
package/src/styles.css CHANGED
@@ -19,3 +19,139 @@
19
19
  background-color: #0b141a;
20
20
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%2319232a' stroke-width='1.2' opacity='0.9'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
21
21
  }
22
+
23
+ /* Botão de ação do cabeçalho de conversa e do aviso de janela. Vive no stylesheet, como o
24
+ .cv-wallpaper, para não depender da configuração de Tailwind do consumidor. */
25
+ .cv-header-action {
26
+ border: 1px solid rgb(203 213 225);
27
+ border-radius: 0.375rem;
28
+ padding: 0.375rem 0.75rem;
29
+ font-size: 0.8125rem;
30
+ background: transparent;
31
+ cursor: pointer;
32
+ }
33
+ .cv-header-action:disabled { opacity: 0.5; cursor: not-allowed; }
34
+ .dark .cv-header-action { border-color: rgb(71 85 105); color: rgb(226 232 240); }
35
+
36
+ .cv-header-icon {
37
+ border: 1px solid rgb(203 213 225);
38
+ border-radius: 0.5rem;
39
+ padding: 0.375rem 0.5rem;
40
+ background: transparent;
41
+ cursor: pointer;
42
+ line-height: 1;
43
+ }
44
+ .cv-header-icon--active { background: rgb(241 245 249); border-color: rgb(148 163 184); }
45
+ .cv-header-icon:disabled { opacity: 0.5; cursor: not-allowed; }
46
+ .dark .cv-header-icon { border-color: rgb(71 85 105); }
47
+ .dark .cv-header-icon--active { background: rgb(51 65 85); }
48
+
49
+ .cv-header-action--primary {
50
+ background: rgb(37 99 235);
51
+ border-color: rgb(37 99 235);
52
+ color: white;
53
+ font-weight: 500;
54
+ }
55
+ .cv-header-action--danger {
56
+ background: rgb(239 68 68);
57
+ border-color: rgb(239 68 68);
58
+ color: white;
59
+ font-weight: 500;
60
+ }
61
+
62
+ .cv-subtab {
63
+ padding: 0.5rem 0.75rem;
64
+ font-size: 0.8125rem;
65
+ color: rgb(100 116 139);
66
+ background: transparent;
67
+ border: 0;
68
+ border-bottom: 2px solid transparent;
69
+ cursor: pointer;
70
+ }
71
+ .cv-subtab--active { color: inherit; font-weight: 500; border-bottom-color: rgb(37 99 235); }
72
+
73
+ /* Pills de estado da conversa. No stylesheet, como os demais cv-*, para não depender do Tailwind
74
+ do consumidor. */
75
+ .cv-pill {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ gap: 0.25rem;
79
+ font-size: 0.6875rem;
80
+ font-weight: 500;
81
+ line-height: 1;
82
+ padding: 0.25rem 0.5rem;
83
+ border-radius: 9999px;
84
+ background: rgb(241 245 249);
85
+ color: rgb(71 85 105);
86
+ }
87
+ .cv-pill--success { background: rgb(220 252 231); color: rgb(21 128 61); }
88
+ .cv-pill--warning { background: rgb(254 243 199); color: rgb(146 64 14); }
89
+ .cv-pill--danger { background: rgb(255 237 213); color: rgb(194 65 12); }
90
+ .dark .cv-pill { background: rgb(51 65 85); color: rgb(203 213 225); }
91
+ .dark .cv-pill--success { background: rgb(20 83 45); color: rgb(187 247 208); }
92
+ .dark .cv-pill--warning { background: rgb(120 53 15); color: rgb(253 230 138); }
93
+ .dark .cv-pill--danger { background: rgb(124 45 18); color: rgb(254 215 170); }
94
+
95
+ /* Linha da inbox: realce e hover cobrem a linha toda, incluindo checkbox, barra de janela e pills. */
96
+ .cv-row:hover { background: #f0f2f5; }
97
+ .cv-row--active, .cv-row--active:hover { background: #f0f2f5; }
98
+ .dark .cv-row:hover { background: rgb(30 41 59); }
99
+ .dark .cv-row--active, .dark .cv-row--active:hover { background: rgb(30 41 59); }
100
+
101
+ /* Voltar do master/detail: existe só onde lista e conversa não cabem lado a lado. */
102
+ .cv-back {
103
+ border: 1px solid rgb(203 213 225);
104
+ border-radius: 0.5rem;
105
+ padding: 0.25rem 0.5rem;
106
+ background: transparent;
107
+ cursor: pointer;
108
+ line-height: 1;
109
+ }
110
+ @media (min-width: 1024px) { .cv-back { display: none; } }
111
+ .dark .cv-back { border-color: rgb(71 85 105); color: rgb(226 232 240); }
112
+
113
+ /* Utilitários de densidade: em tela estreita o texto das ações vira ícone, porque rótulo longo
114
+ empilha e come a altura útil da conversa. */
115
+ .cv-only-wide { display: none; }
116
+ @media (min-width: 1024px) { .cv-only-wide { display: inline; } }
117
+ .cv-only-narrow { display: inline-flex; }
118
+ @media (min-width: 1024px) { .cv-only-narrow { display: none; } }
119
+
120
+ .cv-menu { position: relative; }
121
+ .cv-menu-panel {
122
+ position: absolute;
123
+ right: 0;
124
+ top: calc(100% + 0.25rem);
125
+ z-index: 20;
126
+ min-width: 12rem;
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 0.25rem;
130
+ padding: 0.375rem;
131
+ border: 1px solid rgb(203 213 225);
132
+ border-radius: 0.5rem;
133
+ background: #fff;
134
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
135
+ }
136
+ .dark .cv-menu-panel { background: rgb(30 41 59); border-color: rgb(71 85 105); }
137
+ .cv-menu-panel .cv-header-action { text-align: left; }
138
+
139
+ /* Área de toque mínima do padrão de responsividade (44px). Só em tela estreita: no desktop o
140
+ cursor é preciso e botões de 44px viram blocos desproporcionais. */
141
+ @media (max-width: 1023px) {
142
+ .cv-touch { min-width: 44px; min-height: 44px; }
143
+ .cv-back { min-width: 44px; min-height: 44px; }
144
+ .cv-menu-panel .cv-touch { min-height: 44px; }
145
+ }
146
+
147
+ /* Em tela estreita o painel do menu ancora na viewport, não no botão: ancorado ao botão ele
148
+ escapava pela borda quando o gatilho ficava perto da esquerda, e o texto era cortado. */
149
+ @media (max-width: 1023px) {
150
+ .cv-menu-panel {
151
+ position: fixed;
152
+ right: 0.75rem;
153
+ left: auto;
154
+ top: auto;
155
+ max-width: calc(100vw - 1.5rem);
156
+ }
157
+ }
package/src/types.ts CHANGED
@@ -20,9 +20,46 @@ export interface ConversationsFeatures {
20
20
  darkMode?: boolean
21
21
  }
22
22
 
23
+ /**
24
+ * Recorte do bloco `interactive` da Meta que a UI precisa para desenhar o menu. Fica solto (e não
25
+ * espelhando o contrato inteiro) porque o que chega do banco é o payload cru já enviado ao
26
+ * WhatsApp: qualquer campo que a UI não conheça é ignorado, nunca causa erro de render.
27
+ */
28
+ export interface InteractiveOption {
29
+ id: string
30
+ title: string
31
+ description?: string
32
+ }
33
+
34
+ export interface InteractiveSection {
35
+ title?: string
36
+ rows?: InteractiveOption[]
37
+ }
38
+
39
+ export interface InteractivePayload {
40
+ type?: 'button' | 'list' | string
41
+ header?: { text?: string }
42
+ body?: { text?: string }
43
+ footer?: { text?: string }
44
+ action?: {
45
+ /** Rótulo do botão que abre a lista — só existe em `type: 'list'`. */
46
+ button?: string
47
+ sections?: InteractiveSection[]
48
+ buttons?: { reply?: InteractiveOption }[]
49
+ }
50
+ }
51
+
52
+ /** Como o cliente respondeu a um menu: por botão ou por item de lista. */
53
+ export type InteractiveSelection = {
54
+ readonly kind: 'button' | 'list'
55
+ readonly option: InteractiveOption
56
+ }
57
+
23
58
  export interface MessagePayload {
24
59
  id: string
25
- type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template'
60
+ type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template' | 'interactive'
61
+ /** Payload cru da mensagem. Em `type: 'interactive'`, carrega o menu que o cliente vê. */
62
+ payload?: InteractivePayload | null
26
63
  content?: string
27
64
  caption?: string
28
65
  mediaUrl?: string
@@ -39,6 +76,14 @@ export interface MessagePayload {
39
76
  readAt?: string
40
77
  agentName?: string | null
41
78
  templateName?: string
79
+ /**
80
+ * Veredito de moderação vindo do backend — a UI só exibe, nunca calcula. Dicionário no browser
81
+ * seria peso morto e daria veredito diferente por versão de cliente.
82
+ *
83
+ * `null`/ausente = não avaliado (moderação desligada, ou mensagem anterior ao recurso), que é
84
+ * diferente de avaliado e limpo.
85
+ */
86
+ moderation?: { isOffensive: boolean; terms: string[] } | null
42
87
  isFirstInGroup?: boolean
43
88
  isLastInGroup?: boolean
44
89
  }
@@ -13,6 +13,32 @@ function getInitialDark(): boolean {
13
13
  return window.matchMedia('(prefers-color-scheme: dark)').matches
14
14
  }
15
15
 
16
+ /**
17
+ * Leitura passiva do tema do host: observa a classe `dark` no `<html>` e não escreve nada.
18
+ *
19
+ * Existe porque `useDarkMode` é um controlador — ele grava a classe e persiste a preferência. Um
20
+ * componente que só precisa escolher cor não pode usá-lo: bastava renderizar o mapa de fluxos
21
+ * para o app inteiro do host trocar de tema, seguindo o `prefers-color-scheme` do sistema em vez
22
+ * da configuração da aplicação.
23
+ */
24
+ export function useIsDarkTheme(): boolean {
25
+ const [isDark, setIsDark] = useState(
26
+ () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
27
+ )
28
+
29
+ useEffect(() => {
30
+ const root = document.documentElement
31
+ const observer = new MutationObserver(() => setIsDark(root.classList.contains('dark')))
32
+
33
+ observer.observe(root, { attributes: true, attributeFilter: ['class'] })
34
+ setIsDark(root.classList.contains('dark'))
35
+
36
+ return () => observer.disconnect()
37
+ }, [])
38
+
39
+ return isDark
40
+ }
41
+
16
42
  export function useDarkMode(): { isDark: boolean; toggle: () => void } {
17
43
  const [isDark, setIsDark] = useState(getInitialDark)
18
44
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Detecta tela estreita para decisões que CSS não resolve — como abrir ou não um painel por padrão.
3
+ *
4
+ * O breakpoint é o mesmo das classes `cv-only-*` e `.cv-back` (1024px). Duplicado aqui porque
5
+ * JavaScript não lê media query do stylesheet; se um dia divergirem, o sintoma é painel abrindo
6
+ * numa largura onde o resto da UI já mudou de modo.
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+
11
+ export const NARROW_MAX_WIDTH_PX = 1023
12
+
13
+ export function useIsNarrow(): boolean {
14
+ const [isNarrow, setIsNarrow] = useState(
15
+ () => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${NARROW_MAX_WIDTH_PX}px)`).matches,
16
+ )
17
+
18
+ useEffect(() => {
19
+ const query = window.matchMedia(`(max-width: ${NARROW_MAX_WIDTH_PX}px)`)
20
+ const handleChange = (event: MediaQueryListEvent): void => setIsNarrow(event.matches)
21
+
22
+ query.addEventListener('change', handleChange)
23
+ setIsNarrow(query.matches)
24
+
25
+ return () => query.removeEventListener('change', handleChange)
26
+ }, [])
27
+
28
+ return isNarrow
29
+ }
@@ -1,64 +1,109 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { useConversations } from './providers/ConversationsProvider'
3
- import type { ConversationSummary } from './providers/types'
3
+ import { conversationsOf } from './lib/paginated'
4
+ import type { ConversationSummary, ListConversationsParams } from './providers/types'
4
5
 
5
- interface UseWaitingNotificationsResult {
6
+ const DEFAULT_POLL_INTERVAL_MS = 10_000
7
+ const DEFAULT_LIMIT = 50
8
+
9
+ export interface UseWaitingNotificationsLabels {
10
+ /** Título da notificação do sistema. Recebe a conversa para o host escolher nome × número. */
11
+ title: (conversation: ConversationSummary) => string
12
+ body: (conversation: ConversationSummary) => string
13
+ }
14
+
15
+ export interface UseWaitingNotificationsParams {
16
+ /**
17
+ * Repassado cru ao `fetchConversations`. É o que permite filtrar não lidas **no servidor** em
18
+ * vez de baixar a lista inteira e contar no cliente: um painel com milhares de conversas não
19
+ * pode paginar 50 por vez atrás de quem tem `unread > 0`.
20
+ */
21
+ readonly params?: ListConversationsParams
22
+ readonly intervalMs?: number
23
+ /** Desliga o polling sem desmontar quem chama — útil com a aba em segundo plano. */
24
+ readonly enabled?: boolean
25
+ readonly icon?: string
26
+ readonly labels?: Partial<UseWaitingNotificationsLabels>
27
+ }
28
+
29
+ export interface UseWaitingNotificationsResult {
6
30
  unreadCount: number
7
31
  conversations: ConversationSummary[]
32
+ /**
33
+ * Releitura sob demanda. Existe porque o polling é o piso, não o mecanismo: quem já recebe SSE
34
+ * ou acabou de marcar tudo como lido sabe da mudança antes do próximo tick, e esperar 10s para
35
+ * o contador acompanhar faz a interface parecer travada.
36
+ */
37
+ refresh: () => Promise<void>
38
+ }
39
+
40
+ const DEFAULT_LABELS: UseWaitingNotificationsLabels = {
41
+ title: (conversation) => conversation.clientName ?? conversation.whatsappNumber,
42
+ body: (conversation) => conversation.lastContent ?? 'Nova mensagem',
8
43
  }
9
44
 
10
- export function useWaitingNotifications(): UseWaitingNotificationsResult {
11
- const conversationsCtx = useConversations()
45
+ export function useWaitingNotifications(params?: UseWaitingNotificationsParams): UseWaitingNotificationsResult {
46
+ const conversationsContext = useConversations()
12
47
  const [conversations, setConversations] = useState<ConversationSummary[]>([])
13
48
  const previousUnreadMap = useRef<Map<string, number>>(new Map())
14
49
  const notifiedIds = useRef<Set<string>>(new Set())
50
+ // Trava de reentrância: com a rede lenta, o tick de 10s dispara sobre a busca anterior ainda em
51
+ // voo, e duas respostas fora de ordem fazem o contador oscilar e a notificação repetir.
52
+ const isPollingRef = useRef(false)
53
+
54
+ const isEnabled = params?.enabled ?? true
55
+ const intervalMs = params?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS
56
+ const icon = params?.icon
57
+ const listParams = params?.params
58
+ const labelTitle = params?.labels?.title ?? DEFAULT_LABELS.title
59
+ const labelBody = params?.labels?.body ?? DEFAULT_LABELS.body
15
60
 
16
- const pollConversations = useCallback(async () => {
17
- if (!conversationsCtx) return
61
+ const refresh = useCallback(async () => {
62
+ if (!conversationsContext || isPollingRef.current) return
63
+ isPollingRef.current = true
18
64
 
19
65
  try {
20
- const result = await conversationsCtx.api.fetchConversations({ limit: 50 })
66
+ const result = conversationsOf(
67
+ await conversationsContext.api.fetchConversations({ limit: DEFAULT_LIMIT, ...listParams }),
68
+ )
21
69
  setConversations(result)
22
70
 
23
71
  const currentMap = new Map<string, number>()
24
- for (const conv of result) {
25
- currentMap.set(conv.id, conv.unread)
26
- }
72
+ for (const conversation of result) currentMap.set(conversation.id, conversation.unread)
27
73
 
28
- for (const conv of result) {
29
- if (conv.unread <= 0) continue
74
+ for (const conversation of result) {
75
+ if (conversation.unread <= 0) continue
30
76
 
31
- const prev = previousUnreadMap.current.get(conv.id) ?? 0
77
+ const previousUnread = previousUnreadMap.current.get(conversation.id) ?? 0
78
+ if (conversation.unread <= previousUnread || notifiedIds.current.has(conversation.id)) continue
32
79
 
33
- if (conv.unread > prev && !notifiedIds.current.has(conv.id)) {
34
- notifiedIds.current.add(conv.id)
35
-
36
- if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
37
- const title = conv.clientName ?? conv.whatsappNumber
38
- const body = conv.lastContent ?? 'Nova mensagem'
39
- new Notification(title, { body, icon: '/favicon.ico' })
40
- }
80
+ notifiedIds.current.add(conversation.id)
81
+ if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
82
+ new Notification(labelTitle(conversation), { body: labelBody(conversation), ...(icon ? { icon } : {}) })
41
83
  }
42
84
  }
43
85
 
44
86
  previousUnreadMap.current = currentMap
45
87
  } catch {
46
- // Silently ignore polling errors
88
+ // Contador de badge não interrompe o atendimento: uma falha de rede some no próximo tick.
89
+ } finally {
90
+ isPollingRef.current = false
47
91
  }
48
- }, [conversationsCtx])
92
+ }, [conversationsContext, listParams, icon, labelTitle, labelBody])
49
93
 
50
94
  useEffect(() => {
95
+ if (!isEnabled) return
96
+
51
97
  if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'default') {
52
98
  Notification.requestPermission()
53
99
  }
54
100
 
55
- pollConversations()
56
-
57
- const interval = setInterval(pollConversations, 10_000)
101
+ void refresh()
102
+ const interval = setInterval(() => void refresh(), intervalMs)
58
103
  return () => clearInterval(interval)
59
- }, [pollConversations])
104
+ }, [refresh, isEnabled, intervalMs])
60
105
 
61
- const unreadCount = conversations.reduce((sum, conv) => sum + conv.unread, 0)
106
+ const unreadCount = conversations.reduce((sum, conversation) => sum + conversation.unread, 0)
62
107
 
63
- return { unreadCount, conversations }
108
+ return { unreadCount, conversations, refresh }
64
109
  }