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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/chunk-4R6Y43DQ.js +726 -0
  2. package/dist/chunk-NV2RZ5KT.js +56 -0
  3. package/dist/{chunk-ZDURDZTM.js → chunk-OGRRHQQW.js} +1 -41
  4. package/dist/flows/index.js +6 -4
  5. package/dist/index.d.ts +323 -111
  6. package/dist/index.js +1032 -954
  7. package/dist/preview/index.d.ts +172 -0
  8. package/dist/preview/index.js +576 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-C0PtaO7S.d.ts +207 -0
  11. package/package.json +10 -3
  12. package/src/Avatar.tsx +18 -3
  13. package/src/ChannelIcon.tsx +87 -0
  14. package/src/ConversationContextPanel.tsx +106 -0
  15. package/src/ConversationDocumentsPanel.tsx +107 -0
  16. package/src/ConversationHeader.tsx +239 -0
  17. package/src/ConversationListItem.tsx +36 -5
  18. package/src/ConversationLocalesProvider.tsx +16 -0
  19. package/src/ConversationRow.tsx +137 -0
  20. package/src/DateDivider.tsx +16 -3
  21. package/src/MediaRenderer.tsx +9 -9
  22. package/src/MessageBubble.tsx +24 -2
  23. package/src/MessageComposer.tsx +15 -2
  24. package/src/Wallpaper.tsx +4 -2
  25. package/src/WindowExpiredNotice.tsx +57 -0
  26. package/src/conversationChannel.test.ts +53 -0
  27. package/src/conversationChannel.ts +146 -0
  28. package/src/conversationTranscript.test.ts +65 -0
  29. package/src/conversationTranscript.ts +64 -0
  30. package/src/conversationWindow.test.ts +90 -0
  31. package/src/conversationWindow.ts +78 -0
  32. package/src/flows/FlowMapCanvas.tsx +2 -2
  33. package/src/hooks/useConversationDocuments.ts +4 -2
  34. package/src/index.ts +73 -4
  35. package/src/lib/cn.ts +15 -0
  36. package/src/lib/phone.ts +34 -0
  37. package/src/preview/ConversationPreview.tsx +148 -0
  38. package/src/preview/createMockConversationsApi.ts +111 -0
  39. package/src/preview/createMockSSEProvider.ts +40 -0
  40. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  41. package/src/preview/createPreviewWebhookClient.ts +99 -0
  42. package/src/preview/index.ts +40 -0
  43. package/src/preview/mockEventSource.ts +53 -0
  44. package/src/preview/preview.test.ts +175 -0
  45. package/src/preview/previewFixtures.ts +153 -0
  46. package/src/preview/previewStore.ts +193 -0
  47. package/src/preview/startPreviewScript.ts +60 -0
  48. package/src/providers/types.ts +36 -2
  49. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  50. package/src/styles.css +136 -0
  51. package/src/types.ts +8 -0
  52. package/src/useDarkMode.ts +26 -0
  53. package/src/useIsNarrow.ts +29 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Estado em memória que alimenta o preview de atendimento humano. Mock de API e mock de SSE
3
+ * compartilham este store de propósito: se cada um tivesse dados próprios, um evento anunciaria
4
+ * mensagem nova e o refetch da lista devolveria o estado antigo — a inbox pareceria funcionar e
5
+ * estaria mentindo, que é exatamente o defeito que o preview deveria expor.
6
+ *
7
+ * Os nomes de canal e de evento espelham o servidor (`conv:<whatsappNumber>`, `global`,
8
+ * `message`/`message-status`/`mode-changed`/`data-changed`). Fidelidade de vocabulário é o que
9
+ * permite trocar mock por servidor real sem tocar na UI.
10
+ */
11
+
12
+ import type { MessagePayload } from '../types'
13
+ import type { ConversationSummary } from '../providers/types'
14
+
15
+ export const GLOBAL_CHANNEL = 'global'
16
+
17
+ export function conversationChannel(conversationId: string): string {
18
+ return `conv:${conversationId}`
19
+ }
20
+
21
+ export type PreviewEmission = {
22
+ readonly channel: string
23
+ readonly event: string
24
+ readonly payload: Record<string, unknown>
25
+ }
26
+
27
+ export type PreviewStoreListener = (emission: PreviewEmission) => void
28
+
29
+ export type AppendMessageParams = {
30
+ readonly conversationId: string
31
+ readonly content: string
32
+ readonly direction: MessagePayload['direction']
33
+ readonly sender: MessagePayload['sender']
34
+ }
35
+
36
+ export type SetModeParams = {
37
+ readonly conversationId: string
38
+ readonly mode: ConversationSummary['mode']
39
+ readonly assignedUserId?: string | undefined
40
+ }
41
+
42
+ export type ListConversationsFilters = {
43
+ readonly waitingHuman?: boolean
44
+ readonly search?: string
45
+ }
46
+
47
+ export type PreviewStore = {
48
+ listConversations(filters?: ListConversationsFilters): ConversationSummary[]
49
+ listMessages(conversationId: string): MessagePayload[]
50
+ appendMessage(params: AppendMessageParams): MessagePayload
51
+ setMode(params: SetModeParams): void
52
+ requestHuman(conversationId: string): void
53
+ markRead(conversationId: string): void
54
+ subscribe(channel: string, listener: PreviewStoreListener): () => void
55
+ }
56
+
57
+ export type CreatePreviewStoreParams = {
58
+ readonly conversations: readonly ConversationSummary[]
59
+ readonly messages: Readonly<Record<string, readonly MessagePayload[]>>
60
+ // Injetável para o teste conseguir asserir timestamps sem depender do relógio.
61
+ readonly now?: () => Date
62
+ }
63
+
64
+ export function createPreviewStore(params: CreatePreviewStoreParams): PreviewStore {
65
+ const conversations = params.conversations.map((conversation) => ({ ...conversation }))
66
+ const messages = new Map<string, MessagePayload[]>(
67
+ Object.entries(params.messages).map(([conversationId, list]) => [conversationId, [...list]]),
68
+ )
69
+ const listeners = new Map<string, Set<PreviewStoreListener>>()
70
+ const now = params.now ?? ((): Date => new Date())
71
+
72
+ let messageSequence = 0
73
+
74
+ function emit(emission: PreviewEmission): void {
75
+ for (const listener of listeners.get(emission.channel) ?? []) listener(emission)
76
+ }
77
+
78
+ // Toda mutação avisa o canal global: é o sinal de "refaça a query" que mantém a lista coerente
79
+ // com o que o canal da conversa acabou de anunciar.
80
+ function emitDataChanged(): void {
81
+ emit({ channel: GLOBAL_CHANNEL, event: 'data-changed', payload: {} })
82
+ }
83
+
84
+ function findConversation(conversationId: string): ConversationSummary | undefined {
85
+ return conversations.find((conversation) => conversation.id === conversationId)
86
+ }
87
+
88
+ return {
89
+ listConversations(filters?: ListConversationsFilters): ConversationSummary[] {
90
+ return conversations
91
+ .filter((conversation) => (filters?.waitingHuman ? conversation.waitingHuman : true))
92
+ .filter((conversation) => {
93
+ const search = filters?.search?.toLowerCase()
94
+ if (!search) return true
95
+ return (
96
+ conversation.whatsappNumber.includes(search) ||
97
+ (conversation.clientName?.toLowerCase().includes(search) ?? false)
98
+ )
99
+ })
100
+ .map((conversation) => ({ ...conversation }))
101
+ .sort((left, right) => right.lastAt.localeCompare(left.lastAt))
102
+ },
103
+
104
+ listMessages(conversationId: string): MessagePayload[] {
105
+ return [...(messages.get(conversationId) ?? [])]
106
+ },
107
+
108
+ appendMessage(appendParams: AppendMessageParams): MessagePayload {
109
+ messageSequence += 1
110
+ const timestamp = now().toISOString()
111
+ const message: MessagePayload = {
112
+ id: `preview-${messageSequence}`,
113
+ type: 'text',
114
+ content: appendParams.content,
115
+ direction: appendParams.direction,
116
+ sender: appendParams.sender,
117
+ timestamp,
118
+ status: appendParams.direction === 'outbound' ? 'sent' : undefined,
119
+ }
120
+
121
+ const conversationMessages = messages.get(appendParams.conversationId) ?? []
122
+ messages.set(appendParams.conversationId, [...conversationMessages, message])
123
+
124
+ const conversation = findConversation(appendParams.conversationId)
125
+ if (conversation) {
126
+ conversation.lastContent = appendParams.content
127
+ conversation.lastDirection = appendParams.direction
128
+ conversation.lastAt = timestamp
129
+ if (appendParams.direction === 'inbound') {
130
+ conversation.lastInboundAt = timestamp
131
+ conversation.unread += 1
132
+ }
133
+ }
134
+
135
+ // O servidor emite APENAS `{ direction, sender }` neste evento — é ping de "refaça a
136
+ // query", não entrega de dados. Emitir a mensagem inteira aqui deixaria o mock mais
137
+ // generoso que a realidade, e uma UI que lesse `event.data.content` funcionaria no preview
138
+ // e quebraria em produção.
139
+ emit({
140
+ channel: conversationChannel(appendParams.conversationId),
141
+ event: 'message',
142
+ payload: { direction: message.direction, sender: message.sender },
143
+ })
144
+ emitDataChanged()
145
+
146
+ return message
147
+ },
148
+
149
+ setMode(modeParams: SetModeParams): void {
150
+ const conversation = findConversation(modeParams.conversationId)
151
+ if (!conversation) return
152
+
153
+ conversation.mode = modeParams.mode
154
+ conversation.assignedUserId = modeParams.assignedUserId ?? null
155
+ // Assumir a conversa é o que atende ao pedido de humano — deixar a flag acesa manteria a
156
+ // conversa na fila de espera para sempre.
157
+ if (modeParams.mode === 'human') conversation.waitingHuman = false
158
+
159
+ emit({
160
+ channel: conversationChannel(modeParams.conversationId),
161
+ event: 'mode-changed',
162
+ payload: { mode: conversation.mode, assignedUserId: conversation.assignedUserId },
163
+ })
164
+ emitDataChanged()
165
+ },
166
+
167
+ requestHuman(conversationId: string): void {
168
+ const conversation = findConversation(conversationId)
169
+ if (!conversation) return
170
+
171
+ conversation.waitingHuman = true
172
+ emitDataChanged()
173
+ },
174
+
175
+ markRead(conversationId: string): void {
176
+ const conversation = findConversation(conversationId)
177
+ if (!conversation) return
178
+
179
+ conversation.unread = 0
180
+ emitDataChanged()
181
+ },
182
+
183
+ subscribe(channel: string, listener: PreviewStoreListener): () => void {
184
+ const channelListeners = listeners.get(channel) ?? new Set<PreviewStoreListener>()
185
+ channelListeners.add(listener)
186
+ listeners.set(channel, channelListeners)
187
+
188
+ return () => {
189
+ channelListeners.delete(listener)
190
+ }
191
+ },
192
+ }
193
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Roteiro que mantém o preview vivo: sem tráfego chegando, a inbox é uma tela estática e as
3
+ * transições que o atendente precisa testar (fila de espera enchendo, handoff, devolução ao bot)
4
+ * nunca acontecem.
5
+ *
6
+ * O roteiro é cíclico e determinístico — mesma ordem a cada execução. Aleatoriedade tornaria um
7
+ * defeito visto uma vez difícil de reencontrar.
8
+ */
9
+
10
+ import type { PreviewStore } from './previewStore'
11
+
12
+ export type PreviewScriptStep = (store: PreviewStore) => void
13
+
14
+ export const DEFAULT_PREVIEW_SCRIPT: readonly PreviewScriptStep[] = [
15
+ (store) =>
16
+ store.appendMessage({
17
+ conversationId: '5511988887777',
18
+ content: 'pode trocar o óleo por azeite?',
19
+ direction: 'inbound',
20
+ sender: 'customer',
21
+ }),
22
+ (store) => store.requestHuman('5511988887777'),
23
+ (store) =>
24
+ store.appendMessage({
25
+ conversationId: '5511955554444',
26
+ content: 'e o troco?',
27
+ direction: 'inbound',
28
+ sender: 'customer',
29
+ }),
30
+ (store) => store.setMode({ conversationId: '5511977776666', mode: 'human', assignedUserId: 'agent-2' }),
31
+ (store) =>
32
+ store.appendMessage({
33
+ conversationId: '5511977776666',
34
+ content: 'Oi Diego, sou a Ana. Já vi seu pedido.',
35
+ direction: 'outbound',
36
+ sender: 'agent',
37
+ }),
38
+ (store) => store.setMode({ conversationId: '5511966665555', mode: 'bot' }),
39
+ ]
40
+
41
+ export type StartPreviewScriptParams = {
42
+ readonly store: PreviewStore
43
+ readonly intervalMs?: number
44
+ readonly steps?: readonly PreviewScriptStep[]
45
+ }
46
+
47
+ const DEFAULT_INTERVAL_MS = 4000
48
+
49
+ export function startPreviewScript(params: StartPreviewScriptParams): () => void {
50
+ const steps = params.steps ?? DEFAULT_PREVIEW_SCRIPT
51
+ const intervalMs = params.intervalMs ?? DEFAULT_INTERVAL_MS
52
+ let index = 0
53
+
54
+ const timer = setInterval(() => {
55
+ steps[index % steps.length]?.(params.store)
56
+ index += 1
57
+ }, intervalMs)
58
+
59
+ return () => clearInterval(timer)
60
+ }
@@ -1,4 +1,5 @@
1
1
  import type { MessagePayload } from '../types'
2
+ import type { ConversationChannel } from '../conversationChannel'
2
3
 
3
4
  export interface ConversationsApi {
4
5
  fetchMessages(conversationId: string, params?: { limit?: number; before?: string }): Promise<MessagePayload[]>
@@ -24,14 +25,47 @@ export interface ConversationsApi {
24
25
  getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
25
26
  }
26
27
 
28
+ /**
29
+ * Superfície mínima de stream que o pacote consome — exatamente o que `useConversationRealtime`
30
+ * usa: assinar 'message', desassinar e fechar. Deliberadamente estrutural em vez de
31
+ * `EventSource`: sem servidor HTTP não existe `EventSource`, e é isso que impediria alimentar a
32
+ * inbox com dados mockados em desenvolvimento. Um `EventSource` nativo satisfaz este tipo, então
33
+ * quem já implementa `SSEProvider` continua válido sem mudança.
34
+ */
35
+ export interface ConversationEventSource {
36
+ addEventListener(type: string, listener: (event: MessageEvent) => void): void
37
+ removeEventListener(type: string, listener: (event: MessageEvent) => void): void
38
+ close(): void
39
+ }
40
+
41
+ /**
42
+ * Eventos nomeados que o servidor realmente emite (`event: <nome>` no fio). O canal por conversa
43
+ * é `conv:<whatsappNumber>`; o global só emite `data-changed`, como sinal de "refaça a query".
44
+ * Tipar `addEventListener` como `string` em vez de `'message'` existe por isto: quem assina
45
+ * precisa alcançar `message-status` e `mode-changed`, não só `message`.
46
+ */
47
+ export const CONVERSATION_STREAM_EVENTS = ['message', 'message-status', 'mode-changed'] as const
48
+ export type ConversationStreamEvent = (typeof CONVERSATION_STREAM_EVENTS)[number]
49
+
50
+ export const GLOBAL_STREAM_EVENTS = ['data-changed'] as const
51
+ export type GlobalStreamEvent = (typeof GLOBAL_STREAM_EVENTS)[number]
52
+
27
53
  export interface SSEProvider {
28
- connectConversationStream(conversationId: string): EventSource
29
- connectGlobalStream(): EventSource
54
+ connectConversationStream(conversationId: string): ConversationEventSource
55
+ connectGlobalStream(): ConversationEventSource
30
56
  }
31
57
 
32
58
  export interface ConversationSummary {
33
59
  id: string
60
+ /**
61
+ * @deprecated Use `contactId` com `channel`. Mantido obrigatório para não quebrar quem já
62
+ * consome; some quando o segundo canal entrar em produção.
63
+ */
34
64
  whatsappNumber: string
65
+ /** Identificador neutro do contato. Ausente = usa `whatsappNumber`. */
66
+ contactId?: string
67
+ /** Ausente = `whatsapp`, o comportamento de antes desta mudança. */
68
+ channel?: ConversationChannel
35
69
  clientName?: string
36
70
  lastContent?: string
37
71
  lastDirection?: 'inbound' | 'outbound'
@@ -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
@@ -39,6 +39,14 @@ export interface MessagePayload {
39
39
  readAt?: string
40
40
  agentName?: string | null
41
41
  templateName?: string
42
+ /**
43
+ * Veredito de moderação vindo do backend — a UI só exibe, nunca calcula. Dicionário no browser
44
+ * seria peso morto e daria veredito diferente por versão de cliente.
45
+ *
46
+ * `null`/ausente = não avaliado (moderação desligada, ou mensagem anterior ao recurso), que é
47
+ * diferente de avaliado e limpo.
48
+ */
49
+ moderation?: { isOffensive: boolean; terms: string[] } | null
42
50
  isFirstInGroup?: boolean
43
51
  isLastInGroup?: boolean
44
52
  }
@@ -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
+ }