@adatechnology/conversations-ui 0.1.0 → 0.2.0

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 (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2166 -509
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +2 -2
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +289 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +155 -0
  49. package/src/workspace/useComposerQueue.ts +220 -0
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ insertAtCursor,
5
+ submitQuickReply,
6
+ validateQuickReplyInput,
7
+ type QuickRepliesWorkspaceApi,
8
+ type QuickRepliesWorkspaceEditing,
9
+ } from './useQuickRepliesWorkspace'
10
+ import { DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS } from './labels'
11
+ import type { QuickReply } from './quickReply.types'
12
+
13
+ const LABELS = DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS
14
+
15
+ const VALID_EDITING: QuickRepliesWorkspaceEditing = {
16
+ id: null,
17
+ title: 'Saudação',
18
+ shortcut: 'ola',
19
+ body: 'Olá!',
20
+ attachments: [],
21
+ }
22
+ const SAVED: QuickReply = { id: '1', title: 'Saudação', shortcut: 'ola', body: 'Olá!' }
23
+
24
+ describe('validateQuickReplyInput', () => {
25
+ it('aceita título, atalho e corpo dentro do limite', () => {
26
+ expect(validateQuickReplyInput({ title: 'Saudação', shortcut: 'ola', body: 'Olá!' }, LABELS)).toEqual({})
27
+ })
28
+
29
+ it('rejeita título vazio ou maior que 40 caracteres', () => {
30
+ expect(validateQuickReplyInput({ title: '', shortcut: 'ola', body: 'Olá!' }, LABELS).title).toBeDefined()
31
+ expect(
32
+ validateQuickReplyInput({ title: 'x'.repeat(41), shortcut: 'ola', body: 'Olá!' }, LABELS).title,
33
+ ).toBeDefined()
34
+ })
35
+
36
+ it('rejeita atalho com maiúscula, espaço ou acento', () => {
37
+ expect(validateQuickReplyInput({ title: 'T', shortcut: 'Ola', body: 'B' }, LABELS).shortcut).toBeDefined()
38
+ expect(validateQuickReplyInput({ title: 'T', shortcut: 'ol a', body: 'B' }, LABELS).shortcut).toBeDefined()
39
+ expect(validateQuickReplyInput({ title: 'T', shortcut: 'olá', body: 'B' }, LABELS).shortcut).toBeDefined()
40
+ })
41
+
42
+ it('aceita atalho com hífen e número', () => {
43
+ expect(validateQuickReplyInput({ title: 'T', shortcut: 'doc-2', body: 'B' }, LABELS).shortcut).toBeUndefined()
44
+ })
45
+
46
+ it('rejeita corpo vazio ou maior que 1000 caracteres', () => {
47
+ expect(validateQuickReplyInput({ title: 'T', shortcut: 'ola', body: '' }, LABELS).body).toBeDefined()
48
+ expect(validateQuickReplyInput({ title: 'T', shortcut: 'ola', body: 'x'.repeat(1001) }, LABELS).body).toBeDefined()
49
+ })
50
+ })
51
+
52
+ describe('submitQuickReply', () => {
53
+ it('cria quando id é null e chama createQuickReply', async () => {
54
+ const api: QuickRepliesWorkspaceApi = { createQuickReply: async () => SAVED }
55
+ const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
56
+ expect(result).toEqual({ outcome: 'saved', quickReply: SAVED })
57
+ })
58
+
59
+ it('atualiza quando há id e chama updateQuickReply com ele', async () => {
60
+ let calledWith: string | undefined
61
+ const api: QuickRepliesWorkspaceApi = {
62
+ updateQuickReply: async (id) => {
63
+ calledWith = id
64
+ return SAVED
65
+ },
66
+ }
67
+ const result = await submitQuickReply({ api, editing: { ...VALID_EDITING, id: '1' }, labels: LABELS })
68
+ expect(result).toEqual({ outcome: 'saved', quickReply: SAVED })
69
+ expect(calledWith).toBe('1')
70
+ })
71
+
72
+ it('manda attachmentUploadIds só quando o host tem a porta de upload (QR-30)', async () => {
73
+ let receivedInput: unknown
74
+ const withUpload: QuickRepliesWorkspaceApi = {
75
+ createQuickReply: async (input) => {
76
+ receivedInput = input
77
+ return SAVED
78
+ },
79
+ uploadQuickReplyAttachment: async () => ({ uploadId: 'x', filename: 'x', mimeType: 'x', sizeBytes: 1 }),
80
+ }
81
+ const editingWithAttachments = {
82
+ ...VALID_EDITING,
83
+ attachments: [{ uploadId: 'a', filename: 'a.pdf', mimeType: 'application/pdf', sizeBytes: 1 }],
84
+ }
85
+ await submitQuickReply({ api: withUpload, editing: editingWithAttachments, labels: LABELS })
86
+ expect((receivedInput as { attachmentUploadIds?: readonly string[] }).attachmentUploadIds).toEqual(['a'])
87
+
88
+ const withoutUpload: QuickRepliesWorkspaceApi = {
89
+ createQuickReply: async (input) => {
90
+ receivedInput = input
91
+ return SAVED
92
+ },
93
+ }
94
+ await submitQuickReply({ api: withoutUpload, editing: editingWithAttachments, labels: LABELS })
95
+ expect((receivedInput as { attachmentUploadIds?: readonly string[] }).attachmentUploadIds).toBeUndefined()
96
+ })
97
+
98
+ it('não chama a api quando a validação falha', async () => {
99
+ let called = false
100
+ const api: QuickRepliesWorkspaceApi = {
101
+ createQuickReply: async () => {
102
+ called = true
103
+ return SAVED
104
+ },
105
+ }
106
+ const result = await submitQuickReply({ api, editing: { ...VALID_EDITING, title: '' }, labels: LABELS })
107
+ expect(result.outcome).toBe('invalid')
108
+ expect(called).toBe(false)
109
+ })
110
+
111
+ it('409 com code QUICK_REPLY_SHORTCUT_TAKEN vira erro no campo atalho', async () => {
112
+ const api: QuickRepliesWorkspaceApi = {
113
+ createQuickReply: async () => {
114
+ throw { code: 'QUICK_REPLY_SHORTCUT_TAKEN' }
115
+ },
116
+ }
117
+ const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
118
+ expect(result).toEqual({ outcome: 'rejected', fieldErrors: { shortcut: LABELS.shortcutTaken } })
119
+ })
120
+
121
+ it('error.details[] com vários campos aponta cada um no seu campo', async () => {
122
+ const api: QuickRepliesWorkspaceApi = {
123
+ createQuickReply: async () => {
124
+ throw {
125
+ details: [
126
+ { field: 'title', message: 'Título repetido' },
127
+ { field: 'shortcut', message: 'Atalho em uso' },
128
+ ],
129
+ }
130
+ },
131
+ }
132
+ const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
133
+ expect(result).toEqual({
134
+ outcome: 'rejected',
135
+ fieldErrors: { title: 'Título repetido', shortcut: 'Atalho em uso' },
136
+ })
137
+ })
138
+
139
+ it('erro sem details vira o rótulo do formulário, nunca a mensagem crua da exceção', async () => {
140
+ const api: QuickRepliesWorkspaceApi = {
141
+ createQuickReply: async () => {
142
+ throw new Error('ECONNRESET: socket hang up')
143
+ },
144
+ }
145
+ const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
146
+ expect(result).toEqual({ outcome: 'rejected', fieldErrors: {}, formError: LABELS.saveError })
147
+ })
148
+
149
+ it('409 com code aninhado em error.response.data.error (axios) vira erro no atalho', async () => {
150
+ const api: QuickRepliesWorkspaceApi = {
151
+ createQuickReply: async () => {
152
+ throw {
153
+ response: {
154
+ data: {
155
+ error: {
156
+ code: 'QUICK_REPLY_SHORTCUT_TAKEN',
157
+ message: 'Atalho já existe',
158
+ details: [{ field: 'shortcut', message: 'Atalho já existe' }],
159
+ },
160
+ },
161
+ },
162
+ }
163
+ },
164
+ }
165
+ const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
166
+ expect(result).toEqual({ outcome: 'rejected', fieldErrors: { shortcut: 'Atalho já existe' } })
167
+ })
168
+
169
+ it('erro aninhado em error.error (envelope { error: {...} }) vira erro no atalho', async () => {
170
+ const api: QuickRepliesWorkspaceApi = {
171
+ createQuickReply: async () => {
172
+ throw {
173
+ error: {
174
+ code: 'QUICK_REPLY_SHORTCUT_TAKEN',
175
+ message: 'Atalho já existe',
176
+ details: [{ field: 'shortcut', message: 'Atalho já existe' }],
177
+ },
178
+ }
179
+ },
180
+ }
181
+ const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
182
+ expect(result).toEqual({ outcome: 'rejected', fieldErrors: { shortcut: 'Atalho já existe' } })
183
+ })
184
+
185
+ it('erro aninhado em error.body.error vira erro no atalho', async () => {
186
+ const api: QuickRepliesWorkspaceApi = {
187
+ createQuickReply: async () => {
188
+ throw { body: { error: { code: 'QUICK_REPLY_SHORTCUT_TAKEN' } } }
189
+ },
190
+ }
191
+ const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
192
+ expect(result).toEqual({ outcome: 'rejected', fieldErrors: { shortcut: LABELS.shortcutTaken } })
193
+ })
194
+
195
+ it('erro que se referencia (error.error === error) não trava em recursão infinita', async () => {
196
+ const circular: Record<string, unknown> = {}
197
+ circular.error = circular
198
+ const api: QuickRepliesWorkspaceApi = {
199
+ createQuickReply: async () => {
200
+ throw circular
201
+ },
202
+ }
203
+ const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
204
+ expect(result).toEqual({ outcome: 'rejected', fieldErrors: {}, formError: LABELS.saveError })
205
+ })
206
+
207
+ it('editando sem updateQuickReply na porta, rejeita com saveUnavailable em vez de fingir sucesso', async () => {
208
+ const api: QuickRepliesWorkspaceApi = {}
209
+ const result = await submitQuickReply({ api, editing: { ...VALID_EDITING, id: '1' }, labels: LABELS })
210
+ expect(result).toEqual({ outcome: 'rejected', fieldErrors: {}, formError: LABELS.saveUnavailable })
211
+ })
212
+ })
213
+
214
+ describe('insertAtCursor', () => {
215
+ it('insere o marcador na posição do cursor', () => {
216
+ expect(insertAtCursor('Olá !', 4, 4, '{{nome}}')).toEqual({ text: 'Olá {{nome}}!', caret: 12 })
217
+ })
218
+
219
+ it('troca a seleção pelo marcador', () => {
220
+ expect(insertAtCursor('Olá NOME!', 4, 8, '{{nome}}')).toEqual({ text: 'Olá {{nome}}!', caret: 12 })
221
+ })
222
+ })
@@ -0,0 +1,426 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { filterQuickReplies } from './quickReplySearch'
3
+ import { moveAttachment } from './quickReplyAttachmentUpload'
4
+ import { useQuickReplyAttachmentUploads } from './useQuickReplyAttachmentUploads'
5
+ import type { PendingAttachmentUpload } from './useQuickReplyAttachmentUploads'
6
+ import type { AttachmentFileRejection } from './quickReplyAttachmentUpload'
7
+ import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
8
+ import type { QuickReply, QuickReplyAttachment, QuickReplyInput } from './quickReply.types'
9
+ import type { QuickRepliesWorkspaceLabels } from './labels'
10
+
11
+ export type { PendingAttachmentUpload } from './useQuickReplyAttachmentUploads'
12
+
13
+ /**
14
+ * Portas que a tela de cadastro precisa. `create`/`update`/`delete` ausentes não impedem a leitura
15
+ * — só tiram a ação correspondente, a mesma regra de capacidade do resto do pacote.
16
+ */
17
+ export interface QuickRepliesWorkspaceApi {
18
+ readonly listQuickReplies?: (params?: { search?: string }) => Promise<QuickReply[]>
19
+ readonly createQuickReply?: (input: QuickReplyInput) => Promise<QuickReply>
20
+ readonly updateQuickReply?: (id: string, input: QuickReplyInput) => Promise<QuickReply>
21
+ readonly deleteQuickReply?: (id: string) => Promise<void>
22
+ /** Sem esta porta, a seção de anexos não aparece — nem no formulário. */
23
+ readonly uploadQuickReplyAttachment?: (
24
+ file: File,
25
+ options?: { onProgress?: (fraction: number) => void; signal?: AbortSignal },
26
+ ) => Promise<QuickReplyAttachment>
27
+ }
28
+
29
+ export type QuickReplyFormField = 'title' | 'shortcut' | 'body'
30
+ export type QuickRepliesWorkspaceFieldErrors = Partial<Record<QuickReplyFormField, string>>
31
+
32
+ export type QuickRepliesWorkspaceEditing = {
33
+ /** `null` é a criação; presente é edição do registro com este id. */
34
+ readonly id: string | null
35
+ readonly title: string
36
+ readonly shortcut: string
37
+ readonly body: string
38
+ /** Já subidos — a ordem daqui é a ordem de envio salva (QR-30). */
39
+ readonly attachments: readonly QuickReplyAttachment[]
40
+ }
41
+
42
+ const TITLE_MAX_LENGTH = 40
43
+ const BODY_MAX_LENGTH = 1000
44
+ const SHORTCUT_PATTERN = /^[a-z0-9-]{1,20}$/
45
+
46
+ /** Casca genérica de erro de API com `code` e `details[]` (`apis.md`) — sem acoplar a um cliente HTTP específico. */
47
+ type ApiErrorShape = { readonly code?: unknown; readonly details?: unknown }
48
+
49
+ /**
50
+ * Desembrulha a casca `{ code, details }` de onde quer que o cliente HTTP do host a tenha
51
+ * pendurado. `apis.md` define o envelope `{ error: { code, message, details } }`, mas cada cliente
52
+ * (fetch cru, axios, o `Error` que o `ConversationsProvider` relança) expõe o objeto lançado de um
53
+ * jeito diferente — `error.error`, `error.response.data.error` (axios) ou `error.body.error`
54
+ * (alguns wrappers de fetch). Sem isto, a rejeição real do servidor nunca chega ao formulário e o
55
+ * operador só vê "não foi possível salvar", mesmo quando a API já mandou o campo certo.
56
+ */
57
+ const MAX_ERROR_ENVELOPE_DEPTH = 3
58
+
59
+ /**
60
+ * Desce no máximo `MAX_ERROR_ENVELOPE_DEPTH` níveis: um erro que se referencia (`error.error ===
61
+ * error`, comum em `Error` customizado com `cause` circular) faria a recursão nunca terminar.
62
+ */
63
+ function apiErrorEnvelopeOf(error: unknown, depth = 0): ApiErrorShape | undefined {
64
+ if (depth >= MAX_ERROR_ENVELOPE_DEPTH) return undefined
65
+ if (!error || typeof error !== 'object') return undefined
66
+ const candidate = error as Record<string, unknown>
67
+ if ('code' in candidate || 'details' in candidate) return candidate as ApiErrorShape
68
+ const nested =
69
+ (candidate.error as unknown) ??
70
+ (candidate.response as Record<string, unknown> | undefined)?.data ??
71
+ (candidate.body as unknown)
72
+ if (nested && typeof nested === 'object') return apiErrorEnvelopeOf(nested, depth + 1)
73
+ return undefined
74
+ }
75
+
76
+ function fieldErrorsOf(error: unknown): QuickRepliesWorkspaceFieldErrors {
77
+ const envelope = apiErrorEnvelopeOf(error)
78
+ if (!envelope) return {}
79
+ const details = envelope.details
80
+ if (!Array.isArray(details)) return {}
81
+ const result: QuickRepliesWorkspaceFieldErrors = {}
82
+ for (const detail of details) {
83
+ if (!detail || typeof detail !== 'object') continue
84
+ const field = (detail as { field?: unknown }).field
85
+ const message = (detail as { message?: unknown }).message
86
+ if (typeof field === 'string' && typeof message === 'string' && isFormField(field)) {
87
+ result[field] = message
88
+ }
89
+ }
90
+ return result
91
+ }
92
+
93
+ function isFormField(value: string): value is QuickReplyFormField {
94
+ return value === 'title' || value === 'shortcut' || value === 'body'
95
+ }
96
+
97
+ function errorCodeOf(error: unknown): string | undefined {
98
+ const code = apiErrorEnvelopeOf(error)?.code
99
+ return typeof code === 'string' ? code : undefined
100
+ }
101
+
102
+ /** Regras de formato — as mesmas do backend (`QR-08`), verificadas antes de gastar uma chamada. */
103
+ export function validateQuickReplyInput(
104
+ input: QuickReplyInput,
105
+ labels: QuickRepliesWorkspaceLabels,
106
+ ): QuickRepliesWorkspaceFieldErrors {
107
+ const errors: QuickRepliesWorkspaceFieldErrors = {}
108
+ if (!input.title.trim() || input.title.length > TITLE_MAX_LENGTH) errors.title = labels.fieldTitleInvalid
109
+ if (!SHORTCUT_PATTERN.test(input.shortcut)) errors.shortcut = labels.fieldShortcutInvalid
110
+ if (!input.body.trim() || input.body.length > BODY_MAX_LENGTH) errors.body = labels.fieldBodyInvalid
111
+ return errors
112
+ }
113
+
114
+ export type SubmitQuickReplyParams = {
115
+ readonly api: QuickRepliesWorkspaceApi
116
+ readonly editing: QuickRepliesWorkspaceEditing
117
+ readonly labels: QuickRepliesWorkspaceLabels
118
+ }
119
+
120
+ export type SubmitQuickReplyResult =
121
+ | { readonly outcome: 'saved'; readonly quickReply: QuickReply }
122
+ | { readonly outcome: 'invalid'; readonly fieldErrors: QuickRepliesWorkspaceFieldErrors }
123
+ | {
124
+ readonly outcome: 'rejected'
125
+ readonly fieldErrors: QuickRepliesWorkspaceFieldErrors
126
+ readonly formError?: string
127
+ }
128
+
129
+ /**
130
+ * Toda a lógica de salvar num único lugar testável sem montar o hook: valida, chama a porta certa
131
+ * (criar ou atualizar) e traduz a rejeição — 409 com `code: 'QUICK_REPLY_SHORTCUT_TAKEN'` ou
132
+ * `error.details[]` — no campo que errou.
133
+ */
134
+ export async function submitQuickReply({
135
+ api,
136
+ editing,
137
+ labels,
138
+ }: SubmitQuickReplyParams): Promise<SubmitQuickReplyResult> {
139
+ const input: QuickReplyInput = {
140
+ title: editing.title.trim(),
141
+ shortcut: editing.shortcut.trim(),
142
+ body: editing.body,
143
+ // Só manda a lista quando o host oferece a porta de upload — ausente, `attachmentUploadIds`
144
+ // some do payload e o backend não mexe nos anexos já gravados (contrato do tipo).
145
+ ...(api.uploadQuickReplyAttachment
146
+ ? { attachmentUploadIds: editing.attachments.map((attachment) => attachment.uploadId) }
147
+ : {}),
148
+ }
149
+ const validationErrors = validateQuickReplyInput(input, labels)
150
+ if (Object.keys(validationErrors).length > 0) return { outcome: 'invalid', fieldErrors: validationErrors }
151
+
152
+ const editingId = editing.id
153
+ if (editingId === null && !api.createQuickReply) {
154
+ return { outcome: 'rejected', fieldErrors: {}, formError: labels.saveUnavailable }
155
+ }
156
+ if (editingId !== null && !api.updateQuickReply) {
157
+ return { outcome: 'rejected', fieldErrors: {}, formError: labels.saveUnavailable }
158
+ }
159
+ const save =
160
+ editingId === null
161
+ ? (api.createQuickReply as NonNullable<QuickRepliesWorkspaceApi['createQuickReply']>)
162
+ : (input: QuickReplyInput) =>
163
+ (api.updateQuickReply as NonNullable<QuickRepliesWorkspaceApi['updateQuickReply']>)(editingId, input)
164
+
165
+ try {
166
+ const quickReply = await save(input)
167
+ if (!quickReply) return { outcome: 'invalid', fieldErrors: {} }
168
+ return { outcome: 'saved', quickReply }
169
+ } catch (caught: unknown) {
170
+ const detailErrors = fieldErrorsOf(caught)
171
+ const code = errorCodeOf(caught)
172
+ const shortcutMessage =
173
+ detailErrors.shortcut ?? (code === 'QUICK_REPLY_SHORTCUT_TAKEN' ? labels.shortcutTaken : undefined)
174
+ if (shortcutMessage || Object.keys(detailErrors).length > 0) {
175
+ return {
176
+ outcome: 'rejected',
177
+ fieldErrors: { ...detailErrors, ...(shortcutMessage ? { shortcut: shortcutMessage } : {}) },
178
+ }
179
+ }
180
+ // Nunca `caught.message` cru: é texto de exceção interna (rede, parsing), não algo que o
181
+ // operador deva ler — a mensagem exibida é sempre o rótulo do host (QR-10).
182
+ return {
183
+ outcome: 'rejected',
184
+ fieldErrors: {},
185
+ formError: labels.saveError,
186
+ }
187
+ }
188
+ }
189
+
190
+ export type InsertAtCursorResult = {
191
+ readonly text: string
192
+ readonly caret: number
193
+ }
194
+
195
+ /** Insere `marker` na posição do cursor (ou troca a seleção) — usado pelo botão "Inserir variável". */
196
+ export function insertAtCursor(text: string, start: number, end: number, marker: string): InsertAtCursorResult {
197
+ return { text: text.slice(0, start) + marker + text.slice(end), caret: start + marker.length }
198
+ }
199
+
200
+ export type UseQuickRepliesWorkspaceParams = {
201
+ readonly api: QuickRepliesWorkspaceApi
202
+ readonly labels: QuickRepliesWorkspaceLabels
203
+ /** Sobrescreve o teto por tipo de arquivo. Ausente, usa `DEFAULT_MAX_ATTACHMENT_SIZE_BYTES`. */
204
+ readonly attachmentSizeLimits?: MaxAttachmentSizeBytes
205
+ }
206
+
207
+ export type UseQuickRepliesWorkspaceResult = {
208
+ readonly quickReplies: readonly QuickReply[]
209
+ readonly filtered: readonly QuickReply[]
210
+ readonly isLoading: boolean
211
+ readonly loadError: string | undefined
212
+ readonly search: string
213
+ readonly setSearch: (value: string) => void
214
+ /** Sem `createQuickReply`, a tela só mostra a tabela — nada de criar, editar ou excluir. */
215
+ readonly readOnly: boolean
216
+ readonly canEdit: boolean
217
+ readonly canDelete: boolean
218
+ readonly editing: QuickRepliesWorkspaceEditing | undefined
219
+ readonly startCreate: () => void
220
+ readonly startEdit: (quickReply: QuickReply) => void
221
+ readonly cancelEdit: () => void
222
+ readonly updateField: (field: QuickReplyFormField, value: string) => void
223
+ readonly fieldErrors: QuickRepliesWorkspaceFieldErrors
224
+ readonly isSaving: boolean
225
+ readonly saveError: string | undefined
226
+ readonly submit: () => Promise<void>
227
+ readonly remove: (id: string) => Promise<void>
228
+ /** Excluindo agora — para o botão da linha mostrar o spinner certo (QR-47). */
229
+ readonly deletingId: string | undefined
230
+ /** Sem `uploadQuickReplyAttachment`, a seção de anexos não aparece no formulário (QR-31). */
231
+ readonly hasAttachmentsCapability: boolean
232
+ readonly pendingUploads: readonly PendingAttachmentUpload[]
233
+ readonly addAttachmentFiles: (files: FileList | readonly File[]) => void
234
+ readonly retryAttachmentUpload: (localId: string) => void
235
+ readonly cancelAttachmentUpload: (localId: string) => void
236
+ readonly removeAttachment: (uploadId: string) => void
237
+ readonly moveAttachmentAt: (index: number, direction: -1 | 1) => void
238
+ readonly attachmentRejections: readonly AttachmentFileRejection[]
239
+ readonly dismissAttachmentRejections: () => void
240
+ }
241
+
242
+ /** Estado da tela de cadastro: lista, busca, formulário de criação/edição, anexos e exclusão. */
243
+ export function useQuickRepliesWorkspace({
244
+ api,
245
+ labels,
246
+ attachmentSizeLimits,
247
+ }: UseQuickRepliesWorkspaceParams): UseQuickRepliesWorkspaceResult {
248
+ const [quickReplies, setQuickReplies] = useState<QuickReply[]>([])
249
+ const [isLoading, setIsLoading] = useState(false)
250
+ const [loadError, setLoadError] = useState<string | undefined>(undefined)
251
+ const [search, setSearch] = useState('')
252
+ const [editing, setEditing] = useState<QuickRepliesWorkspaceEditing | undefined>(undefined)
253
+ const [fieldErrors, setFieldErrors] = useState<QuickRepliesWorkspaceFieldErrors>({})
254
+ const [isSaving, setIsSaving] = useState(false)
255
+ const [saveError, setSaveError] = useState<string | undefined>(undefined)
256
+ const [deletingId, setDeletingId] = useState<string | undefined>(undefined)
257
+
258
+ const uploadAttachment = useCallback((attachment: QuickReplyAttachment): void => {
259
+ setEditing((current) => (current ? { ...current, attachments: [...current.attachments, attachment] } : current))
260
+ }, [])
261
+
262
+ const {
263
+ pendingUploads,
264
+ attachmentRejections,
265
+ addAttachmentFiles,
266
+ retryAttachmentUpload,
267
+ cancelAttachmentUpload,
268
+ dismissAttachmentRejections,
269
+ abortAllUploads,
270
+ } = useQuickReplyAttachmentUploads({
271
+ ...(api.uploadQuickReplyAttachment ? { upload: api.uploadQuickReplyAttachment } : {}),
272
+ labels,
273
+ ...(attachmentSizeLimits ? { attachmentSizeLimits } : {}),
274
+ attachmentsCount: editing?.attachments.length ?? 0,
275
+ onUploaded: uploadAttachment,
276
+ })
277
+
278
+ useEffect(() => {
279
+ if (!api.listQuickReplies) return
280
+ let cancelled = false
281
+ setIsLoading(true)
282
+ setLoadError(undefined)
283
+ api
284
+ .listQuickReplies()
285
+ .then((result) => {
286
+ if (!cancelled) setQuickReplies(result)
287
+ })
288
+ .catch((caught: unknown) => {
289
+ if (!cancelled) setLoadError(caught instanceof Error ? caught.message : labels.failure)
290
+ })
291
+ .finally(() => {
292
+ if (!cancelled) setIsLoading(false)
293
+ })
294
+ return () => {
295
+ cancelled = true
296
+ }
297
+ }, [api.listQuickReplies])
298
+
299
+ const filtered = filterQuickReplies({ quickReplies, search })
300
+
301
+ const startCreate = useCallback(() => {
302
+ abortAllUploads()
303
+ dismissAttachmentRejections()
304
+ setEditing({ id: null, title: '', shortcut: '', body: '', attachments: [] })
305
+ setFieldErrors({})
306
+ setSaveError(undefined)
307
+ }, [abortAllUploads, dismissAttachmentRejections])
308
+
309
+ const startEdit = useCallback(
310
+ (quickReply: QuickReply) => {
311
+ abortAllUploads()
312
+ dismissAttachmentRejections()
313
+ setEditing({
314
+ id: quickReply.id,
315
+ title: quickReply.title,
316
+ shortcut: quickReply.shortcut,
317
+ body: quickReply.body,
318
+ attachments: quickReply.attachments ?? [],
319
+ })
320
+ setFieldErrors({})
321
+ setSaveError(undefined)
322
+ },
323
+ [abortAllUploads, dismissAttachmentRejections],
324
+ )
325
+
326
+ const cancelEdit = useCallback(() => {
327
+ abortAllUploads()
328
+ dismissAttachmentRejections()
329
+ setEditing(undefined)
330
+ setFieldErrors({})
331
+ setSaveError(undefined)
332
+ }, [abortAllUploads, dismissAttachmentRejections])
333
+
334
+ const updateField = useCallback((field: QuickReplyFormField, value: string) => {
335
+ setEditing((current) => (current ? { ...current, [field]: value } : current))
336
+ // Editar o campo limpa o erro dele — a mensagem antiga não deve sobreviver à correção.
337
+ setFieldErrors((current) => {
338
+ if (!(field in current)) return current
339
+ const next = { ...current }
340
+ delete next[field]
341
+ return next
342
+ })
343
+ }, [])
344
+
345
+ const submit = useCallback(async () => {
346
+ // Rede de segurança: o botão já fica desabilitado com upload em voo (QR-47), mas salvar antes
347
+ // da resposta chegar mandaria a mensagem sem o anexo que o operador acabou de anexar.
348
+ if (!editing || pendingUploads.length > 0) return
349
+ setIsSaving(true)
350
+ setSaveError(undefined)
351
+ setFieldErrors({})
352
+ const result = await submitQuickReply({ api, editing, labels })
353
+ setIsSaving(false)
354
+ if (result.outcome === 'saved') {
355
+ setQuickReplies((current) =>
356
+ editing.id === null
357
+ ? [result.quickReply, ...current]
358
+ : current.map((item) => (item.id === result.quickReply.id ? result.quickReply : item)),
359
+ )
360
+ setEditing(undefined)
361
+ return
362
+ }
363
+ setFieldErrors(result.fieldErrors)
364
+ if (result.outcome === 'rejected') setSaveError(result.formError)
365
+ }, [editing, api, labels, pendingUploads.length])
366
+
367
+ const remove = useCallback(
368
+ async (id: string) => {
369
+ if (!api.deleteQuickReply) return
370
+ setDeletingId(id)
371
+ try {
372
+ await api.deleteQuickReply(id)
373
+ setQuickReplies((current) => current.filter((item) => item.id !== id))
374
+ } finally {
375
+ setDeletingId(undefined)
376
+ }
377
+ },
378
+ [api],
379
+ )
380
+
381
+ const removeAttachment = useCallback((uploadId: string) => {
382
+ setEditing((current) =>
383
+ current
384
+ ? { ...current, attachments: current.attachments.filter((attachment) => attachment.uploadId !== uploadId) }
385
+ : current,
386
+ )
387
+ }, [])
388
+
389
+ const moveAttachmentAt = useCallback((index: number, direction: -1 | 1) => {
390
+ setEditing((current) =>
391
+ current ? { ...current, attachments: moveAttachment(current.attachments, index, direction) } : current,
392
+ )
393
+ }, [])
394
+
395
+ return {
396
+ quickReplies,
397
+ filtered,
398
+ isLoading,
399
+ loadError,
400
+ search,
401
+ setSearch,
402
+ readOnly: !api.createQuickReply,
403
+ canEdit: Boolean(api.updateQuickReply),
404
+ canDelete: Boolean(api.deleteQuickReply),
405
+ editing,
406
+ startCreate,
407
+ startEdit,
408
+ cancelEdit,
409
+ updateField,
410
+ fieldErrors,
411
+ isSaving,
412
+ saveError,
413
+ submit,
414
+ remove,
415
+ deletingId,
416
+ hasAttachmentsCapability: Boolean(api.uploadQuickReplyAttachment),
417
+ pendingUploads,
418
+ addAttachmentFiles,
419
+ retryAttachmentUpload,
420
+ cancelAttachmentUpload,
421
+ removeAttachment,
422
+ moveAttachmentAt,
423
+ attachmentRejections,
424
+ dismissAttachmentRejections,
425
+ }
426
+ }