@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.
@@ -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 telefone no subtítulo, explicando para onde a mensagem realmente vai. */
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
- * Telefone já formatado para leitura. É o host que formata: máscara de telefone é convenção
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 = { ...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, ...labels }
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}>
@@ -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 { ConversationSimulatorPanel, DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS } from './ConversationSimulatorPanel'
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 type {
22
- WhatsAppTemplateSummary,
23
- WhatsAppTemplateVariableSuggestion,
24
- WhatsAppTemplateSettingsFormLabels,
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 [loadedMessages, loadedTopics, loadedTemplateSettings, loadedTranscription] = await Promise.all([
215
- api.getMessages(),
216
- api.getTopics?.(),
217
- api.getTemplateSettings?.(),
218
- api.getTranscription?.(),
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
- <WhatsAppTemplateSettingsForm {...settingsProps} {...(settingsLabels ? { labels: settingsLabels } : {})} />
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}