@adatechnology/conversations-ui 0.1.0-rc.34 → 0.1.0-rc.36
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/{types-De5aN-E_.d.ts → ConversationSimulatorPanel--5fIzXWY.d.ts} +303 -1
- package/dist/{chunk-CUYYYZWD.js → chunk-BJNRLLDO.js} +393 -2
- package/dist/index.d.ts +69 -8
- package/dist/index.js +123 -92
- package/dist/preview/index.d.ts +5 -210
- package/dist/preview/index.js +36 -242
- package/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/preview/ConversationPreview.tsx +56 -34
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.tsx +46 -4
- package/src/preview/index.ts +19 -1
- package/src/settings/MessagesWorkspace.tsx +113 -10
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +14 -2
- package/src/workspace/ConversationsWorkspace.tsx +82 -5
- package/src/workspace/index.ts +6 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* This source code is proprietary and confidential. Unauthorized copying,
|
|
5
|
+
* modification, distribution, or use of this file, via any medium, is
|
|
6
|
+
* strictly prohibited without prior written permission from Ada Technology.
|
|
7
|
+
*
|
|
8
|
+
* Author: Anderson Filho <andersonfrfilho@gmail.com>
|
|
9
|
+
*
|
|
10
|
+
* Porta do simulador: o que a visão lado-cliente precisa saber fazer, sem canal no nome.
|
|
11
|
+
*
|
|
12
|
+
* O simulador nasceu falando WhatsApp — `PreviewWebhookClient`, payload assinado, mídia por
|
|
13
|
+
* `mediaId`. Só que a mesma casa atende pelo chat do próprio site, e simular ali não é uma segunda
|
|
14
|
+
* tela: é o mesmo painel, no mesmo lugar da conversa, com outro transporte. Sem esta porta cada
|
|
15
|
+
* canal novo viraria uma cópia da tela — e cópia de tela diverge, que é exatamente o que o
|
|
16
|
+
* `ConversationSimulatorPanel` existe para ter parado.
|
|
17
|
+
*
|
|
18
|
+
* O que é específico de canal fica no adaptador, nunca aqui:
|
|
19
|
+
* - **resposta de menu:** a Meta distingue `button_reply` de `list_reply`, e o roteador do fluxo lê
|
|
20
|
+
* campos diferentes; o chat do site manda o rótulo como texto, que é literalmente o que o
|
|
21
|
+
* visitante produz ao tocar no botão do widget. A porta entrega a seleção inteira e deixa cada
|
|
22
|
+
* adaptador escolher a forma de fio.
|
|
23
|
+
* - **mídia:** a Meta entrega por REFERÊNCIA (sobe o arquivo primeiro, o webhook carrega o `id`); o
|
|
24
|
+
* widget manda os BYTES no `FormData`. A porta trafega o `File` que a tela tem em mão — quem
|
|
25
|
+
* tiver passo de upload faz o upload por dentro.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { InteractiveSelection } from '../types'
|
|
29
|
+
import type { PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
30
|
+
import type { PreviewWebhookClient } from './createPreviewWebhookClient'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Tipos de mídia que o cliente pode mandar de dentro do simulador.
|
|
34
|
+
*
|
|
35
|
+
* Subconjunto proposital do que a Meta aceita: `sticker` chega do aparelho, mas não há como
|
|
36
|
+
* escolher um no seletor de arquivo do navegador — oferecer o tipo aqui seria um caminho morto.
|
|
37
|
+
*/
|
|
38
|
+
export type SimulatorMediaKind = 'image' | 'video' | 'audio' | 'document'
|
|
39
|
+
|
|
40
|
+
/** Os tipos que saem do seletor de arquivo. `audio` fica de fora: ele vem do microfone. */
|
|
41
|
+
export const SIMULATOR_FILE_MEDIA_KINDS: readonly SimulatorMediaKind[] = ['image', 'video', 'document']
|
|
42
|
+
|
|
43
|
+
/** Deriva o tipo de mídia a partir do MIME do arquivo escolhido. */
|
|
44
|
+
export function mediaKindOf(mimeType: string): SimulatorMediaKind {
|
|
45
|
+
if (mimeType.startsWith('image/')) return 'image'
|
|
46
|
+
if (mimeType.startsWith('video/')) return 'video'
|
|
47
|
+
if (mimeType.startsWith('audio/')) return 'audio'
|
|
48
|
+
return 'document'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SendSimulatorMediaParams = {
|
|
52
|
+
readonly mediaKind: SimulatorMediaKind
|
|
53
|
+
/** O arquivo do disco ou o áudio recém-gravado. Referência × bytes é decisão do adaptador. */
|
|
54
|
+
readonly file: File
|
|
55
|
+
readonly mimeType?: string
|
|
56
|
+
readonly filename?: string
|
|
57
|
+
readonly caption?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ConversationSimulatorClient = {
|
|
61
|
+
sendText(text: string): Promise<void>
|
|
62
|
+
sendReply(selection: InteractiveSelection): Promise<void>
|
|
63
|
+
/**
|
|
64
|
+
* Ausente = este canal não recebe mídia do cliente, e o compositor não desenha clipe nem
|
|
65
|
+
* microfone. Melhor um botão que não existe do que um que falha ao ser tocado.
|
|
66
|
+
*/
|
|
67
|
+
sendMedia?(params: SendSimulatorMediaParams): Promise<void>
|
|
68
|
+
/**
|
|
69
|
+
* Restringe o que `sendMedia` aceita. Ausente = todos os tipos.
|
|
70
|
+
*
|
|
71
|
+
* Existe porque canal com meia capacidade é comum: o chat do site sobe áudio (a API transcreve)
|
|
72
|
+
* mas não tem rota para imagem. Sem esta lista o clipe e o microfone apareciam juntos, e um dos
|
|
73
|
+
* dois falhava ao ser tocado.
|
|
74
|
+
*/
|
|
75
|
+
readonly acceptedMediaKinds?: readonly SimulatorMediaKind[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Responde se o compositor deve desenhar o affordance daquele tipo. */
|
|
79
|
+
export function acceptsMediaKind(client: ConversationSimulatorClient, kind: SimulatorMediaKind): boolean {
|
|
80
|
+
if (!client.sendMedia) return false
|
|
81
|
+
|
|
82
|
+
return client.acceptedMediaKinds?.includes(kind) ?? true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type ToSimulatorClientParams = {
|
|
86
|
+
readonly client: PreviewWebhookClient
|
|
87
|
+
/** Destino alternativo do upload. Sem isto, usa o do próprio `client`. */
|
|
88
|
+
readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Distingue a porta neutra do cliente WhatsApp legado, que continua aceito na prop.
|
|
93
|
+
*
|
|
94
|
+
* Leitura estrutural e não `instanceof`: os dois são objetos literais devolvidos por fábrica, e o
|
|
95
|
+
* host pode ter montado o seu à mão.
|
|
96
|
+
*/
|
|
97
|
+
export function isConversationSimulatorClient(
|
|
98
|
+
candidate: ConversationSimulatorClient | PreviewWebhookClient,
|
|
99
|
+
): candidate is ConversationSimulatorClient {
|
|
100
|
+
return typeof (candidate as ConversationSimulatorClient).sendReply === 'function'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Adapta o cliente WhatsApp (webhook assinado ou ponte) para a porta neutra.
|
|
105
|
+
*
|
|
106
|
+
* O upload vive aqui dentro porque ele é uma etapa DO CANAL: no caminho da Meta a mídia precisa
|
|
107
|
+
* existir como `id` antes do webhook citá-la. Sem passo de upload disponível, `sendMedia` sai
|
|
108
|
+
* ausente — é o que mantém o clipe escondido em host que não montou destino para o arquivo, o
|
|
109
|
+
* comportamento que já existia antes desta porta.
|
|
110
|
+
*/
|
|
111
|
+
export function toConversationSimulatorClient({
|
|
112
|
+
client,
|
|
113
|
+
uploadMedia,
|
|
114
|
+
}: ToSimulatorClientParams): ConversationSimulatorClient {
|
|
115
|
+
const upload = uploadMedia ?? client.uploadMedia
|
|
116
|
+
|
|
117
|
+
const base: ConversationSimulatorClient = {
|
|
118
|
+
sendText: (text) => client.sendText(text),
|
|
119
|
+
sendReply: (selection) => {
|
|
120
|
+
const reply = { id: selection.option.id, title: selection.option.title }
|
|
121
|
+
return selection.kind === 'button' ? client.sendButtonReply(reply) : client.sendListReply(reply)
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!upload) return base
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
sendMedia: async ({ mediaKind, file, mimeType, filename, caption }) => {
|
|
130
|
+
const uploaded = await upload(file)
|
|
131
|
+
await client.sendMedia({
|
|
132
|
+
// O tipo sai do MIME que o upload devolveu quando ele existe: host que normaliza o formato
|
|
133
|
+
// (áudio gravado em `webm` que sobe como `ogg`) mudava de tipo, e a mídia chegava como
|
|
134
|
+
// documento.
|
|
135
|
+
mediaType: uploaded.mimeType ? mediaKindOf(uploaded.mimeType) : mediaKind,
|
|
136
|
+
mediaId: uploaded.mediaId,
|
|
137
|
+
mimeType: uploaded.mimeType ?? mimeType ?? file.type,
|
|
138
|
+
filename: uploaded.filename ?? filename ?? file.name,
|
|
139
|
+
...(caption ? { caption } : {}),
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -20,15 +20,20 @@
|
|
|
20
20
|
*
|
|
21
21
|
* O efeito de cada envio aparece na thread ao lado pelo mesmo stream que a inbox já assina — este
|
|
22
22
|
* painel não abre conexão própria.
|
|
23
|
+
*
|
|
24
|
+
* A moldura é a mesma em todo canal — o que muda é o transporte, que entra pelo `client`, e duas
|
|
25
|
+
* frases do cabeçalho: dizer "entrega no webhook real" num chat de site descreveria um caminho que
|
|
26
|
+
* ali não existe, e "escreva como o cliente" num visitante anônimo nomeia alguém que ainda não é.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
import type { ReactNode } from 'react'
|
|
26
30
|
|
|
31
|
+
import { CONVERSATION_CHANNEL, DEFAULT_CONVERSATION_CHANNEL, type ConversationChannel } from '../conversationChannel'
|
|
27
32
|
import { ConversationPreview, type ConversationPreviewProps } from './ConversationPreview'
|
|
28
33
|
|
|
29
34
|
export type ConversationSimulatorPanelLabels = {
|
|
30
35
|
readonly title: string
|
|
31
|
-
/** Complementa o
|
|
36
|
+
/** Complementa o identificador no subtítulo, explicando para onde a mensagem realmente vai. */
|
|
32
37
|
readonly destinationHint: string
|
|
33
38
|
readonly close: string
|
|
34
39
|
readonly placeholder: string
|
|
@@ -41,12 +46,47 @@ export const DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS: ConversationSimulatorP
|
|
|
41
46
|
placeholder: 'Escreva como o cliente…',
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
type ChannelWording = Pick<ConversationSimulatorPanelLabels, 'destinationHint' | 'placeholder'>
|
|
50
|
+
|
|
51
|
+
/** Só o que muda de canal para canal; o resto continua vindo do default. */
|
|
52
|
+
const SIMULATOR_PANEL_CHANNEL_WORDING: Readonly<Record<ConversationChannel, ChannelWording>> = {
|
|
53
|
+
[CONVERSATION_CHANNEL.WHATSAPP]: {
|
|
54
|
+
destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
|
|
55
|
+
placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
|
|
56
|
+
},
|
|
57
|
+
[CONVERSATION_CHANNEL.MESSENGER]: {
|
|
58
|
+
destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
|
|
59
|
+
placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
|
|
60
|
+
},
|
|
61
|
+
[CONVERSATION_CHANNEL.INSTAGRAM]: {
|
|
62
|
+
destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
|
|
63
|
+
placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
|
|
64
|
+
},
|
|
65
|
+
[CONVERSATION_CHANNEL.WEBCHAT]: {
|
|
66
|
+
destinationHint: 'entrega na API do chat do site',
|
|
67
|
+
placeholder: 'Escreva como o visitante…',
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rótulos do painel já resolvidos para o canal — útil para o host que monta o cabeçalho por fora. */
|
|
72
|
+
export function simulatorPanelLabelsOf(channel: ConversationChannel | undefined): ConversationSimulatorPanelLabels {
|
|
73
|
+
return {
|
|
74
|
+
...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
|
|
75
|
+
...SIMULATOR_PANEL_CHANNEL_WORDING[channel ?? DEFAULT_CONVERSATION_CHANNEL],
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
44
79
|
export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'placeholder'> & {
|
|
45
80
|
readonly onClose: () => void
|
|
81
|
+
/** Ausente = WhatsApp, que era o único canal antes desta prop existir. */
|
|
82
|
+
readonly channel?: ConversationChannel
|
|
46
83
|
/**
|
|
47
|
-
*
|
|
84
|
+
* Identificador do contato já formatado para leitura — telefone no WhatsApp, apelido no Instagram,
|
|
85
|
+
* "Visitante 3f9c21" no chat do site. É o host que formata: máscara de telefone é convenção
|
|
48
86
|
* regional, e o pacote não tem como saber a do produto.
|
|
49
87
|
*/
|
|
88
|
+
readonly displayHandle?: string
|
|
89
|
+
/** @deprecated Use `displayHandle` — o simulador deixou de ser só telefone. */
|
|
50
90
|
readonly displayNumber?: string
|
|
51
91
|
readonly labels?: Partial<ConversationSimulatorPanelLabels>
|
|
52
92
|
/** Ações extras no cabeçalho — roteiro automático, limpar conversa, trocar de contato. */
|
|
@@ -55,13 +95,15 @@ export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'pl
|
|
|
55
95
|
|
|
56
96
|
export function ConversationSimulatorPanel({
|
|
57
97
|
onClose,
|
|
98
|
+
channel,
|
|
99
|
+
displayHandle,
|
|
58
100
|
displayNumber,
|
|
59
101
|
labels,
|
|
60
102
|
headerActions,
|
|
61
103
|
...previewProps
|
|
62
104
|
}: ConversationSimulatorPanelProps) {
|
|
63
|
-
const text = { ...
|
|
64
|
-
const subtitle = [displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
|
|
105
|
+
const text = { ...simulatorPanelLabelsOf(channel), ...labels }
|
|
106
|
+
const subtitle = [displayHandle ?? displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
|
|
65
107
|
|
|
66
108
|
return (
|
|
67
109
|
<aside className="cv-simulator-panel" aria-label={text.title}>
|
package/src/preview/index.ts
CHANGED
|
@@ -25,9 +25,27 @@ export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
|
25
25
|
|
|
26
26
|
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
27
27
|
|
|
28
|
-
export {
|
|
28
|
+
export {
|
|
29
|
+
ConversationSimulatorPanel,
|
|
30
|
+
DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
|
|
31
|
+
simulatorPanelLabelsOf,
|
|
32
|
+
} from './ConversationSimulatorPanel'
|
|
29
33
|
export type { ConversationSimulatorPanelProps, ConversationSimulatorPanelLabels } from './ConversationSimulatorPanel'
|
|
30
34
|
|
|
35
|
+
export {
|
|
36
|
+
acceptsMediaKind,
|
|
37
|
+
mediaKindOf,
|
|
38
|
+
isConversationSimulatorClient,
|
|
39
|
+
toConversationSimulatorClient,
|
|
40
|
+
SIMULATOR_FILE_MEDIA_KINDS,
|
|
41
|
+
} from './ConversationSimulatorClient'
|
|
42
|
+
export type {
|
|
43
|
+
ConversationSimulatorClient,
|
|
44
|
+
SendSimulatorMediaParams,
|
|
45
|
+
SimulatorMediaKind,
|
|
46
|
+
ToSimulatorClientParams,
|
|
47
|
+
} from './ConversationSimulatorClient'
|
|
48
|
+
|
|
31
49
|
export { ConversationPreview } from './ConversationPreview'
|
|
32
50
|
export { mediaTypeOf } from './ConversationPreview'
|
|
33
51
|
export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
|
|
@@ -18,10 +18,11 @@ import {
|
|
|
18
18
|
WhatsAppTemplatesSettings,
|
|
19
19
|
type WhatsAppTemplatesSettingsLabels,
|
|
20
20
|
} from './WhatsAppTemplatesSettings'
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
import {
|
|
22
|
+
WhatsAppTemplateSettingsForm,
|
|
23
|
+
type WhatsAppTemplateSummary,
|
|
24
|
+
type WhatsAppTemplateVariableSuggestion,
|
|
25
|
+
type WhatsAppTemplateSettingsFormLabels,
|
|
25
26
|
} from './WhatsAppTemplateSettingsForm'
|
|
26
27
|
import type {
|
|
27
28
|
WhatsAppCreateTemplateFormLabels,
|
|
@@ -46,6 +47,20 @@ export interface TemplateSettings {
|
|
|
46
47
|
variables: string[]
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Papel adicional de template (ex.: despedida) além do principal (`getTemplateSettings`/
|
|
52
|
+
* `saveTemplateSettings`). Cada papel ganha seu próprio formulário empilhado na mesma aba, com
|
|
53
|
+
* carregamento e salvamento independentes — é o que permite hosts com mais de um papel (o
|
|
54
|
+
* financiamento tem boas-vindas + despedida) convergirem para este componente em vez de remontar
|
|
55
|
+
* a tela à parte.
|
|
56
|
+
*/
|
|
57
|
+
export interface MessagesWorkspaceTemplateRole {
|
|
58
|
+
readonly key: string
|
|
59
|
+
readonly labels: { sectionTitle: string; sectionDescription: string }
|
|
60
|
+
getSettings(): Promise<TemplateSettings>
|
|
61
|
+
saveSettings(settings: TemplateSettings): Promise<void>
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
export interface TranscriptionSettings {
|
|
50
65
|
enabled: boolean
|
|
51
66
|
mode: TranscriptionMode
|
|
@@ -61,6 +76,8 @@ export interface MessagesWorkspaceApi {
|
|
|
61
76
|
/** Sem este par, a aba de templates não é desenhada. */
|
|
62
77
|
getTemplateSettings?(): Promise<TemplateSettings>
|
|
63
78
|
saveTemplateSettings?(settings: TemplateSettings): Promise<void>
|
|
79
|
+
/** Papéis adicionais de template (ex.: despedida) além do principal acima. */
|
|
80
|
+
templateRoles?: MessagesWorkspaceTemplateRole[]
|
|
64
81
|
/**
|
|
65
82
|
* Lista de templates aprovados na Meta. Ausente, a aba ainda existe (dá para salvar o nome
|
|
66
83
|
* escolhido), mas nasce sem opções e sem botão de recarregar.
|
|
@@ -150,6 +167,9 @@ export interface MessagesWorkspaceProps {
|
|
|
150
167
|
readonly availableVariables?: WhatsAppTemplateVariableSuggestion[]
|
|
151
168
|
/** Aviso do produto acima da aba de templates (ex.: rota da Graph API ainda não implementada). */
|
|
152
169
|
readonly renderTemplatesNotice?: () => ReactNode
|
|
170
|
+
/** Repassados ao formulário de criação — mesma prévia que `WhatsAppCreateTemplateForm` já suporta. */
|
|
171
|
+
readonly createTemplatePreviewCompanyName?: string
|
|
172
|
+
readonly createTemplateVariableExamples?: readonly string[]
|
|
153
173
|
readonly className?: string
|
|
154
174
|
}
|
|
155
175
|
|
|
@@ -160,9 +180,12 @@ export function MessagesWorkspace({
|
|
|
160
180
|
farewellPlaceholders,
|
|
161
181
|
availableVariables,
|
|
162
182
|
renderTemplatesNotice,
|
|
183
|
+
createTemplatePreviewCompanyName,
|
|
184
|
+
createTemplateVariableExamples,
|
|
163
185
|
className,
|
|
164
186
|
}: MessagesWorkspaceProps) {
|
|
165
187
|
const labels = { ...DEFAULT_LABELS, ...labelsOverride }
|
|
188
|
+
const templateRoles = api.templateRoles ?? []
|
|
166
189
|
|
|
167
190
|
const hasTopics = Boolean(api.getTopics && api.saveTopics)
|
|
168
191
|
const hasTemplates = Boolean(api.getTemplateSettings && api.saveTemplateSettings)
|
|
@@ -186,6 +209,10 @@ export function MessagesWorkspace({
|
|
|
186
209
|
const [savingTemplate, setSavingTemplate] = useState(false)
|
|
187
210
|
const [templateSaved, setTemplateSaved] = useState(false)
|
|
188
211
|
|
|
212
|
+
const [roleSettings, setRoleSettings] = useState<Record<string, TemplateSettings>>({})
|
|
213
|
+
const [savingRole, setSavingRole] = useState<Record<string, boolean>>({})
|
|
214
|
+
const [roleSaved, setRoleSaved] = useState<Record<string, boolean>>({})
|
|
215
|
+
|
|
189
216
|
const [createTemplate, setCreateTemplate] = useState<WhatsAppCreateTemplateState>(EMPTY_CREATE_TEMPLATE)
|
|
190
217
|
const [creatingTemplate, setCreatingTemplate] = useState(false)
|
|
191
218
|
const [createResult, setCreateResult] = useState<WhatsAppCreateTemplateResult | null>(null)
|
|
@@ -211,17 +238,21 @@ export function MessagesWorkspace({
|
|
|
211
238
|
let active = true
|
|
212
239
|
async function load(): Promise<void> {
|
|
213
240
|
try {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
241
|
+
const roles = api.templateRoles ?? []
|
|
242
|
+
const [loadedMessages, loadedTopics, loadedTemplateSettings, loadedTranscription, loadedRoleSettings] =
|
|
243
|
+
await Promise.all([
|
|
244
|
+
api.getMessages(),
|
|
245
|
+
api.getTopics?.(),
|
|
246
|
+
api.getTemplateSettings?.(),
|
|
247
|
+
api.getTranscription?.(),
|
|
248
|
+
Promise.all(roles.map((role) => role.getSettings())),
|
|
249
|
+
])
|
|
220
250
|
if (!active) return
|
|
221
251
|
setMessages(loadedMessages)
|
|
222
252
|
if (loadedTopics) setTopics(loadedTopics)
|
|
223
253
|
if (loadedTemplateSettings) setTemplateSettings(loadedTemplateSettings)
|
|
224
254
|
if (loadedTranscription) setTranscription(loadedTranscription)
|
|
255
|
+
setRoleSettings(Object.fromEntries(roles.map((role, index) => [role.key, loadedRoleSettings[index]])))
|
|
225
256
|
setLoadState('ready')
|
|
226
257
|
} catch {
|
|
227
258
|
if (active) setLoadState('error')
|
|
@@ -279,6 +310,42 @@ export function MessagesWorkspace({
|
|
|
279
310
|
}
|
|
280
311
|
}
|
|
281
312
|
|
|
313
|
+
function handleSaveRole(role: MessagesWorkspaceTemplateRole): (event: FormEvent) => Promise<void> {
|
|
314
|
+
return async (event: FormEvent) => {
|
|
315
|
+
event.preventDefault()
|
|
316
|
+
const settings = roleSettings[role.key]
|
|
317
|
+
if (!settings) return
|
|
318
|
+
setSavingRole((previous) => ({ ...previous, [role.key]: true }))
|
|
319
|
+
try {
|
|
320
|
+
await role.saveSettings(settings)
|
|
321
|
+
setRoleSaved((previous) => ({ ...previous, [role.key]: true }))
|
|
322
|
+
setTimeout(() => setRoleSaved((previous) => ({ ...previous, [role.key]: false })), SAVE_FEEDBACK_MS)
|
|
323
|
+
} finally {
|
|
324
|
+
setSavingRole((previous) => ({ ...previous, [role.key]: false }))
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function handleSelectRoleTemplate(
|
|
330
|
+
role: MessagesWorkspaceTemplateRole,
|
|
331
|
+
name: string,
|
|
332
|
+
template: WhatsAppTemplateSummary | undefined,
|
|
333
|
+
): void {
|
|
334
|
+
setRoleSettings((previous) => {
|
|
335
|
+
const current = previous[role.key] ?? EMPTY_TEMPLATE_SETTINGS
|
|
336
|
+
if (!template) return { ...previous, [role.key]: { ...current, templateName: name } }
|
|
337
|
+
const shouldSeedVariables = template.variableCount > 0 && current.variables.length === 0
|
|
338
|
+
return {
|
|
339
|
+
...previous,
|
|
340
|
+
[role.key]: {
|
|
341
|
+
templateName: name,
|
|
342
|
+
templateLanguage: template.language,
|
|
343
|
+
variables: shouldSeedVariables ? Array.from({ length: template.variableCount }, () => '') : current.variables,
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
|
|
282
349
|
async function handleCreateTemplate(event: FormEvent): Promise<void> {
|
|
283
350
|
event.preventDefault()
|
|
284
351
|
if (!api.createTemplate) return
|
|
@@ -443,9 +510,45 @@ export function MessagesWorkspace({
|
|
|
443
510
|
submitting: creatingTemplate,
|
|
444
511
|
result: createResult,
|
|
445
512
|
labels: labels.createTemplate,
|
|
513
|
+
...(createTemplatePreviewCompanyName ? { previewCompanyName: createTemplatePreviewCompanyName } : {}),
|
|
514
|
+
...(createTemplateVariableExamples ? { variableExamples: createTemplateVariableExamples } : {}),
|
|
446
515
|
},
|
|
447
516
|
}
|
|
448
517
|
: {})}
|
|
518
|
+
{...(templateRoles.length > 0
|
|
519
|
+
? {
|
|
520
|
+
extraRoleForms: (
|
|
521
|
+
<>
|
|
522
|
+
{templateRoles.map((role) => {
|
|
523
|
+
const settings = roleSettings[role.key] ?? EMPTY_TEMPLATE_SETTINGS
|
|
524
|
+
return (
|
|
525
|
+
<WhatsAppTemplateSettingsForm
|
|
526
|
+
key={role.key}
|
|
527
|
+
templates={templates}
|
|
528
|
+
loadingTemplates={loadingTemplates}
|
|
529
|
+
templatesError={templatesError}
|
|
530
|
+
selectedTemplateName={settings.templateName}
|
|
531
|
+
onSelectTemplate={(name, template) => handleSelectRoleTemplate(role, name, template)}
|
|
532
|
+
variables={settings.variables}
|
|
533
|
+
onVariablesChange={(variables) =>
|
|
534
|
+
setRoleSettings((previous) => ({
|
|
535
|
+
...previous,
|
|
536
|
+
[role.key]: { ...(previous[role.key] ?? EMPTY_TEMPLATE_SETTINGS), variables },
|
|
537
|
+
}))
|
|
538
|
+
}
|
|
539
|
+
onSave={handleSaveRole(role)}
|
|
540
|
+
saving={Boolean(savingRole[role.key])}
|
|
541
|
+
saveSuccess={Boolean(roleSaved[role.key])}
|
|
542
|
+
labels={{ ...labels.templateSettings, ...role.labels }}
|
|
543
|
+
{...(api.listTemplates ? { onRefreshTemplates: () => void reloadTemplates() } : {})}
|
|
544
|
+
{...(availableVariables ? { availableVariables } : {})}
|
|
545
|
+
/>
|
|
546
|
+
)
|
|
547
|
+
})}
|
|
548
|
+
</>
|
|
549
|
+
),
|
|
550
|
+
}
|
|
551
|
+
: {})}
|
|
449
552
|
/>
|
|
450
553
|
</div>
|
|
451
554
|
) : null}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { WhatsAppTemplatesSettings } from './WhatsAppTemplatesSettings'
|
|
5
|
+
import type { WhatsAppTemplatesSettingsProps } from './WhatsAppTemplatesSettings'
|
|
6
|
+
|
|
7
|
+
function render(overrides: Partial<WhatsAppTemplatesSettingsProps> = {}): string {
|
|
8
|
+
return renderToStaticMarkup(
|
|
9
|
+
<WhatsAppTemplatesSettings
|
|
10
|
+
templates={[]}
|
|
11
|
+
selectedTemplateName=""
|
|
12
|
+
onSelectTemplate={() => {}}
|
|
13
|
+
variables={[]}
|
|
14
|
+
onVariablesChange={() => {}}
|
|
15
|
+
onSave={() => {}}
|
|
16
|
+
{...overrides}
|
|
17
|
+
/>,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('WhatsAppTemplatesSettings', () => {
|
|
22
|
+
it('renderiza os formulários extras de papel na aba de seleção', () => {
|
|
23
|
+
const markup = render({ extraRoleForms: <div>MARCADOR_PAPEL_EXTRA</div> })
|
|
24
|
+
|
|
25
|
+
expect(markup).toContain('MARCADOR_PAPEL_EXTRA')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('sem extraRoleForms, o comportamento é idêntico ao de antes', () => {
|
|
29
|
+
const markup = render()
|
|
30
|
+
|
|
31
|
+
expect(markup).not.toContain('MARCADOR_PAPEL_EXTRA')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('aceita previewCompanyName e variableExamples em create sem quebrar a tipagem', () => {
|
|
35
|
+
const markup = render({
|
|
36
|
+
create: {
|
|
37
|
+
value: {
|
|
38
|
+
name: '',
|
|
39
|
+
category: 'UTILITY',
|
|
40
|
+
language: 'pt_BR',
|
|
41
|
+
headerType: 'NONE',
|
|
42
|
+
headerText: '',
|
|
43
|
+
bodyText: '',
|
|
44
|
+
footerText: '',
|
|
45
|
+
},
|
|
46
|
+
onChange: () => {},
|
|
47
|
+
onSubmit: () => {},
|
|
48
|
+
previewCompanyName: 'Empresa Teste',
|
|
49
|
+
variableExamples: ['João', '123'],
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(markup).toContain('Criar template')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('sem create, a aba de criação não aparece', () => {
|
|
57
|
+
const markup = render()
|
|
58
|
+
|
|
59
|
+
expect(markup).not.toContain('Criar template')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Segue presentacional: nenhuma chamada de rede aqui. Templates, estado e handlers vêm por props.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useState, type FormEvent } from 'react'
|
|
11
|
+
import { useState, type FormEvent, type ReactNode } from 'react'
|
|
12
12
|
import {
|
|
13
13
|
WhatsAppTemplateSettingsForm,
|
|
14
14
|
type WhatsAppTemplateSummary,
|
|
@@ -59,16 +59,25 @@ export interface WhatsAppTemplatesSettingsProps {
|
|
|
59
59
|
submitting?: boolean
|
|
60
60
|
result?: WhatsAppCreateTemplateResult | null
|
|
61
61
|
labels?: Partial<WhatsAppCreateTemplateFormLabels>
|
|
62
|
+
/** Nome exibido na prévia do template criado; sem isto o form usa seu próprio fallback. */
|
|
63
|
+
previewCompanyName?: string
|
|
64
|
+
variableExamples?: readonly string[]
|
|
62
65
|
}
|
|
63
66
|
labels?: Partial<WhatsAppTemplatesSettingsLabels>
|
|
64
67
|
/** Vocabulário do formulário de seleção — separado de `labels`, que nomeia só as abas daqui. */
|
|
65
68
|
settingsLabels?: Partial<WhatsAppTemplateSettingsFormLabels>
|
|
69
|
+
/**
|
|
70
|
+
* Formulários extras de seleção (um por papel adicional de template, ex.: despedida), empilhados
|
|
71
|
+
* abaixo do principal na mesma sub-aba. Ausente = só o papel principal, comportamento de sempre.
|
|
72
|
+
*/
|
|
73
|
+
extraRoleForms?: ReactNode
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
export function WhatsAppTemplatesSettings({
|
|
69
77
|
labels: labelsOverride,
|
|
70
78
|
settingsLabels,
|
|
71
79
|
create,
|
|
80
|
+
extraRoleForms,
|
|
72
81
|
...settingsProps
|
|
73
82
|
}: WhatsAppTemplatesSettingsProps) {
|
|
74
83
|
const labels = { ...DEFAULT_TEMPLATES_SETTINGS_LABELS, ...labelsOverride }
|
|
@@ -105,7 +114,10 @@ export function WhatsAppTemplatesSettings({
|
|
|
105
114
|
</nav>
|
|
106
115
|
|
|
107
116
|
{tab === TEMPLATE_SETTINGS_TAB.SELECT ? (
|
|
108
|
-
<
|
|
117
|
+
<div className="space-y-6">
|
|
118
|
+
<WhatsAppTemplateSettingsForm {...settingsProps} {...(settingsLabels ? { labels: settingsLabels } : {})} />
|
|
119
|
+
{extraRoleForms}
|
|
120
|
+
</div>
|
|
109
121
|
) : create ? (
|
|
110
122
|
<WhatsAppCreateTemplateForm {...create} />
|
|
111
123
|
) : null}
|