@adatechnology/conversations-ui 0.1.0-rc.20 → 0.1.0-rc.22

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 (34) hide show
  1. package/dist/{chunk-OIDAIVCH.js → chunk-TV4OQRGH.js} +636 -434
  2. package/dist/index.d.ts +142 -9
  3. package/dist/index.js +374 -66
  4. package/dist/preview/index.d.ts +122 -3
  5. package/dist/preview/index.js +137 -21
  6. package/dist/styles.css +112 -0
  7. package/dist/{types-O7kMP1Yn.d.ts → types-C6A_9edv.d.ts} +42 -2
  8. package/package.json +2 -2
  9. package/src/AudioTranscription.test.tsx +115 -0
  10. package/src/AudioTranscription.tsx +249 -0
  11. package/src/ConversationContextPanel.tsx +205 -52
  12. package/src/ConversationLocalesProvider.tsx +28 -0
  13. package/src/MediaRenderer.tsx +37 -6
  14. package/src/MessageBubble.tsx +26 -3
  15. package/src/MessageComposer.tsx +21 -2
  16. package/src/Wallpaper.tsx +27 -13
  17. package/src/conversationTranscript.test.ts +57 -0
  18. package/src/conversationTranscript.ts +29 -4
  19. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  20. package/src/index.ts +11 -0
  21. package/src/preview/ConversationPreview.tsx +13 -4
  22. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  23. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  24. package/src/preview/createPreviewBridgeClient.ts +37 -1
  25. package/src/preview/createPreviewMediaUploader.ts +82 -0
  26. package/src/preview/createPreviewWebhookClient.test.ts +89 -0
  27. package/src/preview/createPreviewWebhookClient.ts +91 -0
  28. package/src/preview/index.ts +9 -0
  29. package/src/preview/previewMediaUploader.test.ts +61 -0
  30. package/src/providers/types.ts +12 -1
  31. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  32. package/src/settings/TranscriptionSettingsForm.tsx +189 -0
  33. package/src/styles.css +122 -0
  34. package/src/types.ts +26 -0
@@ -10,8 +10,10 @@ import {
10
10
  assertPreviewEnvironment,
11
11
  createPreviewWebhookClient,
12
12
  PreviewInProductionError,
13
+ PreviewMediaUploadRejectedError,
13
14
  PreviewWebhookRejectedError,
14
15
  } from './createPreviewWebhookClient'
16
+ import { PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
15
17
 
16
18
  const APP_SECRET = 'dev-app-secret'
17
19
  const FROM = '5511988887777'
@@ -103,3 +105,90 @@ describe('assertPreviewEnvironment', () => {
103
105
  expect(() => assertPreviewEnvironment(false)).not.toThrow()
104
106
  })
105
107
  })
108
+
109
+ /**
110
+ * O microfone do simulador depende disto existir no cliente.
111
+ *
112
+ * Enquanto o upload era prop de quem montava a tela, um produto tinha gravador e o outro não — e a
113
+ * diferença não aparecia em teste nenhum, porque cada host montava o seu. Aqui a garantia é do
114
+ * pacote: cliente montado, upload assinado, id prefixado.
115
+ */
116
+ describe('createPreviewWebhookClient.uploadMedia', () => {
117
+ function createUploadFetch(status = 201): {
118
+ fetchImplementation: typeof fetch
119
+ calls: Array<{ url: string; signature: string; body: string }>
120
+ } {
121
+ const calls: Array<{ url: string; signature: string; body: string }> = []
122
+
123
+ const fetchImplementation = (async (url: string, init?: RequestInit) => {
124
+ const headers = (init?.headers ?? {}) as Record<string, string>
125
+ calls.push({ url: String(url), signature: headers['x-preview-signature'] ?? '', body: String(init?.body) })
126
+ return {
127
+ ok: status >= 200 && status < 300,
128
+ status,
129
+ json: async () => ({ data: { uploadId: 'upl_123' } }),
130
+ } as Response
131
+ }) as unknown as typeof fetch
132
+
133
+ return { fetchImplementation, calls }
134
+ }
135
+
136
+ const audioFile = () => new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' })
137
+
138
+ it('sobe na mesma origem do webhook e devolve o id já prefixado', async () => {
139
+ const { fetchImplementation, calls } = createUploadFetch()
140
+ const client = createPreviewWebhookClient({
141
+ webhookUrl: WEBHOOK_URL,
142
+ appSecret: APP_SECRET,
143
+ from: FROM,
144
+ fetchImplementation,
145
+ })
146
+
147
+ const uploaded = await client.uploadMedia(audioFile())
148
+
149
+ expect(calls[0]?.url).toBe('http://localhost:3000/v1/preview/media')
150
+ expect(uploaded.mediaId).toBe(`${PREVIEW_MEDIA_ID_PREFIX}upl_123`)
151
+ expect(uploaded.mimeType).toBe('audio/ogg')
152
+ })
153
+
154
+ it('assina o MIME com o mesmo HMAC que o servidor confere', async () => {
155
+ const { fetchImplementation, calls } = createUploadFetch()
156
+ const client = createPreviewWebhookClient({
157
+ webhookUrl: WEBHOOK_URL,
158
+ appSecret: APP_SECRET,
159
+ from: FROM,
160
+ fetchImplementation,
161
+ })
162
+
163
+ await client.uploadMedia(audioFile())
164
+
165
+ const expected = `sha256=${createHmac('sha256', APP_SECRET).update('audio/ogg').digest('hex')}`
166
+ expect(calls[0]?.signature).toBe(expected)
167
+ })
168
+
169
+ it('usa caminho relativo quando a URL do webhook não tem origem', async () => {
170
+ const { fetchImplementation, calls } = createUploadFetch()
171
+ const client = createPreviewWebhookClient({
172
+ webhookUrl: '/v1/webhook/whatsapp',
173
+ appSecret: APP_SECRET,
174
+ from: FROM,
175
+ fetchImplementation,
176
+ })
177
+
178
+ await client.uploadMedia(audioFile())
179
+
180
+ expect(calls[0]?.url).toBe('/v1/preview/media')
181
+ })
182
+
183
+ it('avisa com erro próprio quando a rota de mídia recusa', async () => {
184
+ const { fetchImplementation } = createUploadFetch(404)
185
+ const client = createPreviewWebhookClient({
186
+ webhookUrl: WEBHOOK_URL,
187
+ appSecret: APP_SECRET,
188
+ from: FROM,
189
+ fetchImplementation,
190
+ })
191
+
192
+ await expect(client.uploadMedia(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
193
+ })
194
+ })
@@ -25,6 +25,7 @@ import {
25
25
  type InboundMediaType,
26
26
  type InteractiveReplyOption,
27
27
  } from '@adatechnology/meta-whatsapp-contracts/testing'
28
+ import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
28
29
 
29
30
  export type PreviewWebhookClient = {
30
31
  sendText(text: string): Promise<void>
@@ -32,6 +33,19 @@ export type PreviewWebhookClient = {
32
33
  sendListReply(reply: InteractiveReplyOption): Promise<void>
33
34
  sendAudio(mediaId: string): Promise<void>
34
35
  sendMedia(params: SendPreviewMediaParams): Promise<void>
36
+ /**
37
+ * Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
38
+ *
39
+ * Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
40
+ * sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
41
+ * o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
42
+ * por esquecimento. Cliente montado, microfone na tela.
43
+ *
44
+ * Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
45
+ * o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
46
+ * corretamente não desenha o gravador.
47
+ */
48
+ uploadMedia?(file: File): Promise<PreviewUploadedMedia>
35
49
  }
36
50
 
37
51
  export type SendPreviewMediaParams = {
@@ -52,10 +66,25 @@ export type CreatePreviewWebhookClientParams = {
52
66
  readonly appSecret: string
53
67
  readonly from: string
54
68
  readonly phoneNumberId?: string
69
+ /**
70
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
71
+ *
72
+ * O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
73
+ * publica a API em outro host ou versiona o caminho.
74
+ */
75
+ readonly mediaUploadUrl?: string
55
76
  // Escape hatch para teste; em runtime real é sempre o fetch global.
56
77
  readonly fetchImplementation?: typeof fetch
57
78
  }
58
79
 
80
+ /** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
81
+ export class PreviewMediaUploadRejectedError extends Error {
82
+ constructor(readonly status: number) {
83
+ super(`A rota de mídia do simulador recusou o upload (HTTP ${status}).`)
84
+ this.name = 'PreviewMediaUploadRejectedError'
85
+ }
86
+ }
87
+
59
88
  export class PreviewInProductionError extends Error {
60
89
  constructor() {
61
90
  super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
@@ -102,6 +131,53 @@ export async function signPreviewPayload(params: { rawBody: string; appSecret: s
102
131
 
103
132
  const signWithWebCrypto = signPreviewPayload
104
133
 
134
+ export const DEFAULT_MEDIA_UPLOAD_PATH = '/v1/preview/media'
135
+
136
+ /**
137
+ * Mesma origem do webhook: as duas rotas são do mesmo servidor no caso normal.
138
+ *
139
+ * Quando `webhookUrl` é relativa — que é o que sai de um `VITE_API_URL` vazio, com o front servido
140
+ * pela própria API — não há origem para resolver contra, e o caminho relativo já aponta para o
141
+ * lugar certo. `new URL` com base relativa lançaria, e o microfone morreria no `createClient`.
142
+ */
143
+ export function defaultMediaUploadUrl(webhookUrl: string): string {
144
+ try {
145
+ return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString()
146
+ } catch {
147
+ return DEFAULT_MEDIA_UPLOAD_PATH
148
+ }
149
+ }
150
+
151
+ /**
152
+ * O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
153
+ *
154
+ * O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
155
+ * do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
156
+ */
157
+ export function createPreviewMediaPoster(params: {
158
+ readonly url: string
159
+ readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>
160
+ readonly fetchImplementation?: typeof fetch
161
+ }): (file: File) => Promise<PreviewUploadedMedia> {
162
+ return createPreviewMediaUploader({
163
+ upload: async (request) => {
164
+ const performRequest = params.fetchImplementation ?? fetch
165
+ const extraHeaders = (await params.headers?.(request.mimeType)) ?? {}
166
+
167
+ const response = await performRequest(params.url, {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json', ...extraHeaders },
170
+ body: JSON.stringify(request),
171
+ })
172
+
173
+ if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status)
174
+
175
+ const body = (await response.json()) as { data: { uploadId: string } }
176
+ return { uploadId: body.data.uploadId }
177
+ },
178
+ })
179
+ }
180
+
105
181
  export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
106
182
  const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
107
183
  // Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
@@ -119,6 +195,20 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
119
195
  if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
120
196
  }
121
197
 
198
+ /**
199
+ * Assina o MIME, não o binário — mesmo contrato da rota.
200
+ *
201
+ * Passar megabytes de base64 pelo HMAC do navegador travaria a aba a cada nota de voz; o que a
202
+ * assinatura protege aqui é o acesso à rota, e o binário já tem teto de tamanho no servidor.
203
+ */
204
+ const uploadMedia = createPreviewMediaPoster({
205
+ url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
206
+ headers: async (mimeType) => ({
207
+ 'x-preview-signature': await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret }),
208
+ }),
209
+ ...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
210
+ })
211
+
122
212
  const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
123
213
 
124
214
  return {
@@ -127,5 +217,6 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
127
217
  sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
128
218
  sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
129
219
  sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
220
+ uploadMedia,
130
221
  }
131
222
  }
@@ -25,6 +25,9 @@ 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'
29
+ export type { ConversationSimulatorPanelProps, ConversationSimulatorPanelLabels } from './ConversationSimulatorPanel'
30
+
28
31
  export { ConversationPreview } from './ConversationPreview'
29
32
  export { mediaTypeOf } from './ConversationPreview'
30
33
  export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
@@ -38,6 +41,9 @@ export {
38
41
  assertPreviewEnvironment,
39
42
  PreviewInProductionError,
40
43
  PreviewWebhookRejectedError,
44
+ PreviewMediaUploadRejectedError,
45
+ createPreviewMediaPoster,
46
+ DEFAULT_MEDIA_UPLOAD_PATH,
41
47
  } from './createPreviewWebhookClient'
42
48
  export type {
43
49
  PreviewWebhookClient,
@@ -59,3 +65,6 @@ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource
59
65
  export { previewFileBase64 } from './previewMediaSource'
60
66
  export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
61
67
  export type { MediaTypesPreviewProps } from './MediaTypesPreview'
68
+
69
+ export { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
70
+ export type { CreatePreviewMediaUploaderParams, PreviewMediaUploadRequest } from './createPreviewMediaUploader'
@@ -0,0 +1,61 @@
1
+ /**
2
+ * O teste que importa aqui é o do prefixo: ele é duplicado entre este pacote (UI) e o
3
+ * meta-whatsapp-module (backend), e divergir significa o simulador gerar um id que o servidor não
4
+ * reconhece — o áudio chega, o backend busca na Meta, volta 404 e ninguém entende por quê.
5
+ */
6
+
7
+ import { describe, expect, it } from 'bun:test'
8
+ import { PREVIEW_MEDIA_ID_PREFIX as CONTRACT_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
9
+
10
+ import { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
11
+
12
+ describe('createPreviewMediaUploader', () => {
13
+ it('reexporta o prefixo do contrato, sem cópia própria', () => {
14
+ expect(PREVIEW_MEDIA_ID_PREFIX).toBe(CONTRACT_PREFIX)
15
+ })
16
+
17
+ it('devolve o id prefixado com o uploadId da rota', async () => {
18
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'chave/do/objeto' }) })
19
+
20
+ const result = await upload(new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' }))
21
+
22
+ expect(result.mediaId).toBe(`${CONTRACT_PREFIX}chave/do/objeto`)
23
+ expect(result.mimeType).toBe('audio/ogg')
24
+ expect(result.filename).toBe('nota.ogg')
25
+ })
26
+
27
+ it('manda o binário em base64 para a rota', async () => {
28
+ let recebido: string | undefined
29
+ const upload = createPreviewMediaUploader({
30
+ upload: async (request) => {
31
+ recebido = request.base64
32
+ return { uploadId: 'k' }
33
+ },
34
+ })
35
+
36
+ await upload(new File([new TextEncoder().encode('audio')], 'a.ogg', { type: 'audio/ogg' }))
37
+
38
+ expect(atob(recebido!)).toBe('audio')
39
+ })
40
+
41
+ // Gravador entrega áudio sem nome, e navegador antigo entrega sem mime.
42
+ it('preenche nome e mime quando o arquivo vem sem eles', async () => {
43
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
44
+
45
+ const result = await upload(new File([new Uint8Array([1])], '', { type: '' }))
46
+
47
+ expect(result.mimeType).toBe('audio/ogg')
48
+ expect(result.filename).toBe('audio.ogg')
49
+ })
50
+
51
+ /**
52
+ * Espalhar o array de bytes como argumentos estoura a pilha em arquivo grande — o erro que só
53
+ * aparece na primeira gravação de verdade, nunca no clipe curto do teste.
54
+ */
55
+ it('converte arquivo grande sem estourar a pilha', async () => {
56
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
57
+ const grande = new File([new Uint8Array(300_000)], 'longo.ogg', { type: 'audio/ogg' })
58
+
59
+ expect((await upload(grande)).mediaId).toContain(CONTRACT_PREFIX)
60
+ })
61
+ })
@@ -1,4 +1,4 @@
1
- import type { MessagePayload } from '../types'
1
+ import type { MessagePayload, MessageTranscription } from '../types'
2
2
  import type { ConversationChannel } from '../conversationChannel'
3
3
 
4
4
  export interface ListConversationsParams {
@@ -134,6 +134,17 @@ export interface ConversationsApi {
134
134
  * nem todo backend expõe a rota — quem não tem continua usando o builder local.
135
135
  */
136
136
  exportTranscript?(conversationId: string): Promise<{ transcript: string; filename: string }>
137
+
138
+ /**
139
+ * Transcreve o áudio de uma mensagem e devolve o resultado.
140
+ *
141
+ * **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
142
+ * nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
143
+ * simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
144
+ *
145
+ * `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
146
+ */
147
+ transcribeAudio?(messageId: string): Promise<MessageTranscription>
137
148
  }
138
149
 
139
150
  /**
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { TranscriptionSettingsForm } from './TranscriptionSettingsForm'
5
+ import type { TranscriptionSettingsFormProps } from './TranscriptionSettingsForm'
6
+
7
+ /**
8
+ * Extrai uma tag pelo atributo, em vez de casar regex com a ordem dos atributos: o React SSR emite
9
+ * `checked=""` antes de `value=""`, então `/value="auto"[^>]*checked/` falha num componente correto.
10
+ */
11
+ function findTag(markup: string, tagPattern: RegExp, contains: string): string | undefined {
12
+ return (markup.match(tagPattern) ?? []).find((tag) => tag.includes(contains))
13
+ }
14
+
15
+ function render(overrides: Partial<TranscriptionSettingsFormProps> = {}): string {
16
+ return renderToStaticMarkup(
17
+ <TranscriptionSettingsForm
18
+ enabled={false}
19
+ onEnabledChange={() => {}}
20
+ mode="onDemand"
21
+ onModeChange={() => {}}
22
+ onSave={() => {}}
23
+ {...overrides}
24
+ />,
25
+ )
26
+ }
27
+
28
+ describe('TranscriptionSettingsForm', () => {
29
+ it('esconde a escolha de modo enquanto estiver desligado', () => {
30
+ const markup = render({ enabled: false })
31
+
32
+ expect(markup).not.toContain('Quando transcrever')
33
+ })
34
+
35
+ it('mostra as duas opções de modo ao ligar', () => {
36
+ const markup = render({ enabled: true })
37
+
38
+ expect(markup).toContain('Quando transcrever')
39
+ expect(markup).toContain('Quando o atendente pedir')
40
+ expect(markup).toContain('Automaticamente')
41
+ })
42
+
43
+ it('marca o modo atual', () => {
44
+ const markup = render({ enabled: true, mode: 'auto' })
45
+ const radios = /<input type="radio"[^>]*>/g
46
+
47
+ expect(findTag(markup, radios, 'value="auto"')).toContain('checked')
48
+ expect(findTag(markup, radios, 'value="onDemand"')).not.toContain('checked')
49
+ })
50
+
51
+ /**
52
+ * "O ambiente consegue" é diferente de "esta empresa quer". Sem o aviso, o lojista ligaria o
53
+ * interruptor num ambiente sem engine, nada apareceria, e nada explicaria por quê.
54
+ */
55
+ it('avisa e bloqueia quando a capacidade não existe no servidor', () => {
56
+ const markup = render({ isAvailable: false })
57
+
58
+ expect(markup).toContain('não está disponível neste ambiente')
59
+ expect(findTag(markup, /<input type="checkbox"[^>]*>/g, 'type="checkbox"')).toContain('disabled')
60
+ })
61
+
62
+ it('não avisa nada quando a capacidade existe', () => {
63
+ const markup = render({ isAvailable: true })
64
+
65
+ expect(markup).not.toContain('não está disponível neste ambiente')
66
+ })
67
+
68
+ it('aceita rótulos do host', () => {
69
+ const markup = render({ labels: { sectionTitle: 'Áudio em texto' } })
70
+
71
+ expect(markup).toContain('Áudio em texto')
72
+ expect(markup).not.toContain('Transcrição de áudio')
73
+ })
74
+
75
+ it('desabilita o salvar enquanto salva', () => {
76
+ const markup = render({ saving: true })
77
+
78
+ expect(markup).toContain('Salvando...')
79
+ expect(findTag(markup, /<button type="submit"[^>]*>/g, 'type="submit"')).toContain('disabled')
80
+ })
81
+ })
@@ -0,0 +1,189 @@
1
+ import type { FormEvent } from 'react'
2
+ import { AudioLines, Save } from 'lucide-react'
3
+ import type { TranscriptionMode } from '../types'
4
+
5
+ export interface TranscriptionSettingsFormLabels {
6
+ sectionTitle: string
7
+ enabled: string
8
+ enabledHint: string
9
+ modeTitle: string
10
+ modeAuto: string
11
+ modeAutoHint: string
12
+ modeOnDemand: string
13
+ modeOnDemandHint: string
14
+ unavailable: string
15
+ save: string
16
+ saving: string
17
+ saveSuccess: string
18
+ }
19
+
20
+ const DEFAULT_LABELS: TranscriptionSettingsFormLabels = {
21
+ sectionTitle: 'Transcrição de áudio',
22
+ enabled: 'Transcrever notas de voz',
23
+ enabledHint: 'Converte o áudio do cliente em texto, que o atendente lê e copia direto da conversa.',
24
+ modeTitle: 'Quando transcrever',
25
+ modeAuto: 'Automaticamente',
26
+ modeAutoHint: 'Toda nota de voz recebida é transcrita na hora. Mais cômodo, consome mais cota.',
27
+ modeOnDemand: 'Quando o atendente pedir',
28
+ modeOnDemandHint: 'Transcreve só ao clicar no botão da conversa. Consome cota apenas com áudio que alguém vai ler.',
29
+ unavailable: 'Transcrição não está disponível neste ambiente — fale com quem administra a instalação.',
30
+ save: 'Salvar',
31
+ saving: 'Salvando...',
32
+ saveSuccess: 'Configuração de transcrição salva.',
33
+ }
34
+
35
+ export interface TranscriptionSettingsFormProps {
36
+ enabled: boolean
37
+ onEnabledChange: (value: boolean) => void
38
+ mode: TranscriptionMode
39
+ onModeChange: (value: TranscriptionMode) => void
40
+ onSave: (event: FormEvent) => void
41
+ /**
42
+ * A capacidade existe no servidor (engine e credencial configurados)?
43
+ *
44
+ * Distinto de `enabled`: isto é "o ambiente consegue", aquilo é "esta empresa quer". Sem a
45
+ * distinção, um lojista ligaria o interruptor num ambiente sem engine e concluiria que o produto
46
+ * está quebrado — o texto nunca apareceria e nada explicaria por quê.
47
+ */
48
+ isAvailable?: boolean
49
+ saving?: boolean
50
+ saveSuccess?: boolean
51
+ labels?: Partial<TranscriptionSettingsFormLabels>
52
+ }
53
+
54
+ /**
55
+ * Interruptor de transcrição por empresa, para a tela de configurações.
56
+ *
57
+ * Puramente apresentacional, como os outros forms daqui: quem persiste é o host. O pacote não sabe a
58
+ * rota, e a política vive nas configurações do módulo (`settings.transcriptionEnabled`), não em
59
+ * variável de ambiente — mudar de ideia sobre transcrever não deveria exigir deploy.
60
+ */
61
+ export function TranscriptionSettingsForm({
62
+ enabled,
63
+ onEnabledChange,
64
+ mode,
65
+ onModeChange,
66
+ onSave,
67
+ isAvailable = true,
68
+ saving = false,
69
+ saveSuccess = false,
70
+ labels: labelsOverride,
71
+ }: TranscriptionSettingsFormProps) {
72
+ const labels = { ...DEFAULT_LABELS, ...labelsOverride }
73
+
74
+ return (
75
+ <section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
76
+ <div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
77
+ <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-1.5">
78
+ <AudioLines size={14} className="text-blue-600 dark:text-blue-400" />
79
+ {labels.sectionTitle}
80
+ </h2>
81
+ </div>
82
+
83
+ <form onSubmit={onSave} className="p-6 space-y-5">
84
+ {!isAvailable && (
85
+ <div className="bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-xl px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
86
+ {labels.unavailable}
87
+ </div>
88
+ )}
89
+
90
+ <label className="flex items-start gap-3 cursor-pointer">
91
+ <input
92
+ type="checkbox"
93
+ checked={enabled}
94
+ onChange={(event) => onEnabledChange(event.target.checked)}
95
+ disabled={!isAvailable}
96
+ className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
97
+ />
98
+ <span>
99
+ <span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{labels.enabled}</span>
100
+ <span className="block text-xs text-gray-500 dark:text-gray-400">{labels.enabledHint}</span>
101
+ </span>
102
+ </label>
103
+
104
+ {/* O modo só aparece quando ligado: escolher "quando transcrever" sem transcrever nada é uma
105
+ pergunta sem consequência, e ler as duas opções gasta atenção do operador à toa. */}
106
+ {enabled && (
107
+ <fieldset disabled={!isAvailable} className="space-y-2.5">
108
+ <legend className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{labels.modeTitle}</legend>
109
+
110
+ <ModeOption
111
+ value="onDemand"
112
+ current={mode}
113
+ onSelect={onModeChange}
114
+ title={labels.modeOnDemand}
115
+ hint={labels.modeOnDemandHint}
116
+ />
117
+ <ModeOption
118
+ value="auto"
119
+ current={mode}
120
+ onSelect={onModeChange}
121
+ title={labels.modeAuto}
122
+ hint={labels.modeAutoHint}
123
+ />
124
+ </fieldset>
125
+ )}
126
+
127
+ {saveSuccess && (
128
+ <div className="bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-800 rounded-xl px-4 py-3 text-sm text-green-700 dark:text-green-400">
129
+ {labels.saveSuccess}
130
+ </div>
131
+ )}
132
+
133
+ <div className="flex justify-end">
134
+ <button
135
+ type="submit"
136
+ disabled={saving || !isAvailable}
137
+ className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
138
+ >
139
+ {saving ? (
140
+ <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
141
+ ) : (
142
+ <Save size={14} />
143
+ )}
144
+ {saving ? labels.saving : labels.save}
145
+ </button>
146
+ </div>
147
+ </form>
148
+ </section>
149
+ )
150
+ }
151
+
152
+ function ModeOption({
153
+ value,
154
+ current,
155
+ onSelect,
156
+ title,
157
+ hint,
158
+ }: {
159
+ value: TranscriptionMode
160
+ current: TranscriptionMode
161
+ onSelect: (value: TranscriptionMode) => void
162
+ title: string
163
+ hint: string
164
+ }) {
165
+ const isSelected = current === value
166
+
167
+ return (
168
+ <label
169
+ className={`flex items-start gap-3 cursor-pointer rounded-xl border px-4 py-3 transition-colors ${
170
+ isSelected
171
+ ? 'border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/30'
172
+ : 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
173
+ }`}
174
+ >
175
+ <input
176
+ type="radio"
177
+ name="transcription-mode"
178
+ value={value}
179
+ checked={isSelected}
180
+ onChange={() => onSelect(value)}
181
+ className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
182
+ />
183
+ <span>
184
+ <span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{title}</span>
185
+ <span className="block text-xs text-gray-500 dark:text-gray-400">{hint}</span>
186
+ </span>
187
+ </label>
188
+ )
189
+ }