@adatechnology/conversations-ui 0.1.1 → 0.2.1

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 +2181 -507
  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 +1 -1
  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 +467 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +328 -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 +169 -0
  49. package/src/workspace/useComposerQueue.ts +229 -0
@@ -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
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Fila de upload de anexo do formulário de cadastro (QR-31/M1), extraída de
3
+ * `useQuickRepliesWorkspace`: até 3 uploads em voo por vez, progresso real por arquivo, e
4
+ * cancelamento de tudo ao trocar de registro (M2) sem `setState` órfão depois do unmount.
5
+ */
6
+
7
+ import { useCallback, useEffect, useRef, useState } from 'react'
8
+ import { createUploadQueue } from './createUploadQueue'
9
+ import { validateAttachmentFiles, type AttachmentFileRejection } from './quickReplyAttachmentUpload'
10
+ import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
11
+ import type { QuickReplyAttachment } from './quickReply.types'
12
+ import type { QuickRepliesWorkspaceLabels } from './labels'
13
+
14
+ /** Item em upload no formulário: estado local, nunca persistido — some ao terminar ou ser removido. */
15
+ export type PendingAttachmentUpload = {
16
+ readonly localId: string
17
+ readonly file: File
18
+ readonly status: 'uploading' | 'error'
19
+ readonly progress: number
20
+ readonly error?: string
21
+ }
22
+
23
+ export type UseQuickReplyAttachmentUploadsParams = {
24
+ readonly upload?: (
25
+ file: File,
26
+ options?: { onProgress?: (fraction: number) => void; signal?: AbortSignal },
27
+ ) => Promise<QuickReplyAttachment>
28
+ readonly labels: QuickRepliesWorkspaceLabels
29
+ /** Sobrescreve o teto por tipo de arquivo. Ausente, usa `DEFAULT_MAX_ATTACHMENT_SIZE_BYTES`. */
30
+ readonly attachmentSizeLimits?: MaxAttachmentSizeBytes
31
+ /** Quantos anexos já confirmados (`editing.attachments.length`) — entra no teto de 10 (QR-31). */
32
+ readonly attachmentsCount: number
33
+ /** Onde o item entra assim que o upload resolve — o hook não sabe de `editing`, só devolve o resultado. */
34
+ readonly onUploaded: (attachment: QuickReplyAttachment) => void
35
+ }
36
+
37
+ export type UseQuickReplyAttachmentUploadsResult = {
38
+ readonly pendingUploads: readonly PendingAttachmentUpload[]
39
+ readonly attachmentRejections: readonly AttachmentFileRejection[]
40
+ readonly addAttachmentFiles: (files: FileList | readonly File[]) => void
41
+ readonly retryAttachmentUpload: (localId: string) => void
42
+ readonly cancelAttachmentUpload: (localId: string) => void
43
+ readonly dismissAttachmentRejections: () => void
44
+ /** Cancela todo upload em voo — trocar de registro sem isso deixaria um `fetch` órfão terminando
45
+ * sozinho e tentando atualizar um estado que já não existe mais. */
46
+ readonly abortAllUploads: () => void
47
+ }
48
+
49
+ /** Estado e orquestração do upload de anexo — sem saber de `editing`, só de arquivos e resultado. */
50
+ export function useQuickReplyAttachmentUploads({
51
+ upload,
52
+ labels,
53
+ attachmentSizeLimits,
54
+ attachmentsCount,
55
+ onUploaded,
56
+ }: UseQuickReplyAttachmentUploadsParams): UseQuickReplyAttachmentUploadsResult {
57
+ const [pendingUploads, setPendingUploads] = useState<readonly PendingAttachmentUpload[]>([])
58
+ const [attachmentRejections, setAttachmentRejections] = useState<readonly AttachmentFileRejection[]>([])
59
+ /** Fila compartilhada (M1): no máximo 3 uploads em voo ao mesmo tempo, somando o que
60
+ * `addAttachmentFiles` e `retryAttachmentUpload` enfileiram — nenhum dos dois abre janela própria. */
61
+ const uploadQueueRef = useRef(createUploadQueue(3))
62
+ /** M2: depois do unmount, nenhuma promessa de upload em voo pode chamar setState — só aborta. */
63
+ const isMountedRef = useRef(true)
64
+
65
+ useEffect(
66
+ () => () => {
67
+ isMountedRef.current = false
68
+ uploadQueueRef.current.abortAll()
69
+ },
70
+ [],
71
+ )
72
+
73
+ const abortAllUploads = useCallback(() => {
74
+ uploadQueueRef.current.abortAll()
75
+ setPendingUploads([])
76
+ }, [])
77
+
78
+ const dismissAttachmentRejections = useCallback(() => setAttachmentRejections([]), [])
79
+
80
+ const cancelAttachmentUpload = useCallback((localId: string) => {
81
+ uploadQueueRef.current.abort(localId)
82
+ setPendingUploads((current) => current.filter((item) => item.localId !== localId))
83
+ }, [])
84
+
85
+ const updatePendingUpload = useCallback((localId: string, patch: Partial<PendingAttachmentUpload>) => {
86
+ setPendingUploads((current) => current.map((item) => (item.localId === localId ? { ...item, ...patch } : item)))
87
+ }, [])
88
+
89
+ /** Sobe um arquivo já validado, através da fila compartilhada (M1): progresso real por
90
+ * `onProgress`, e o resultado vira `onUploaded` na ordem de chegada assim que a promessa
91
+ * resolve. Enfileira e retorna — quem chama não espera. */
92
+ const uploadOneFile = useCallback(
93
+ (localId: string, file: File) => {
94
+ if (!upload) return
95
+ uploadQueueRef.current.enqueue(localId, async (signal) => {
96
+ try {
97
+ const attachment = await upload(file, {
98
+ onProgress: (fraction) => {
99
+ if (isMountedRef.current) updatePendingUpload(localId, { progress: fraction })
100
+ },
101
+ signal,
102
+ })
103
+ if (!isMountedRef.current) return
104
+ onUploaded(attachment)
105
+ setPendingUploads((current) => current.filter((item) => item.localId !== localId))
106
+ } catch (caught: unknown) {
107
+ if (signal.aborted || !isMountedRef.current) return
108
+ updatePendingUpload(localId, {
109
+ status: 'error',
110
+ error: caught instanceof Error ? caught.message : labels.saveError,
111
+ })
112
+ }
113
+ })
114
+ },
115
+ [upload, updatePendingUpload, onUploaded, labels.saveError],
116
+ )
117
+
118
+ const retryAttachmentUpload = useCallback(
119
+ (localId: string) => {
120
+ const item = pendingUploads.find((pending) => pending.localId === localId)
121
+ if (!item) return
122
+ updatePendingUpload(localId, { status: 'uploading', progress: 0, error: undefined })
123
+ uploadOneFile(localId, item.file)
124
+ },
125
+ [pendingUploads, updatePendingUpload, uploadOneFile],
126
+ )
127
+
128
+ /** Valida (teto de 10, tamanho por tipo) ANTES de subir (QR-31) — só o aceito vira upload; o
129
+ * recusado fica em `attachmentRejections` para a tela explicar por quê, sem gastar rede nele. */
130
+ const addAttachmentFiles = useCallback(
131
+ (files: FileList | readonly File[]) => {
132
+ if (!upload) return
133
+ const currentCount = attachmentsCount + pendingUploads.length
134
+ const { accepted, rejected } = validateAttachmentFiles(Array.from(files), currentCount, attachmentSizeLimits)
135
+ setAttachmentRejections(rejected)
136
+ if (accepted.length === 0) return
137
+ const newItems: PendingAttachmentUpload[] = accepted.map((file) => ({
138
+ localId: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,
139
+ file,
140
+ status: 'uploading',
141
+ progress: 0,
142
+ }))
143
+ setPendingUploads((current) => [...current, ...newItems])
144
+ // Até 3 em paralelo (QR-31), somando com retries em voo — a fila compartilhada decide (M1).
145
+ for (const item of newItems) uploadOneFile(item.localId, item.file)
146
+ },
147
+ [upload, attachmentsCount, pendingUploads.length, uploadOneFile, attachmentSizeLimits],
148
+ )
149
+
150
+ return {
151
+ pendingUploads,
152
+ attachmentRejections,
153
+ addAttachmentFiles,
154
+ retryAttachmentUpload,
155
+ cancelAttachmentUpload,
156
+ dismissAttachmentRejections,
157
+ abortAllUploads,
158
+ }
159
+ }
package/src/styles.css CHANGED
@@ -818,6 +818,93 @@
818
818
  border-color: rgb(51 65 85);
819
819
  }
820
820
 
821
+ /* Item da fila com miniatura, estado de envio (QR-47) e saída com transição curta. */
822
+ .cv-attachment-item {
823
+ max-width: 16rem;
824
+ }
825
+ .cv-attachment-item__thumbnail {
826
+ display: block;
827
+ flex: none;
828
+ width: 2rem;
829
+ height: 2rem;
830
+ overflow: hidden;
831
+ border-radius: 0.375rem;
832
+ background: rgb(226 232 240);
833
+ }
834
+ .dark .cv-attachment-item__thumbnail { background: rgb(51 65 85); }
835
+ .cv-attachment-item__thumbnail img { width: 100%; height: 100%; object-fit: cover; }
836
+ .cv-attachment-item__info {
837
+ display: flex;
838
+ flex-direction: column;
839
+ overflow: hidden;
840
+ min-width: 0;
841
+ }
842
+ .cv-attachment-item__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
843
+ .cv-attachment-item__meta { font-size: 0.6875rem; color: rgb(100 116 139); }
844
+ .dark .cv-attachment-item__meta { color: rgb(148 163 184); }
845
+ .cv-attachment-item--failed .cv-attachment-item__meta { color: rgb(220 38 38); }
846
+ .dark .cv-attachment-item--failed .cv-attachment-item__meta { color: rgb(248 113 113); }
847
+ .cv-attachment-item--sent .cv-attachment-item__meta { color: rgb(22 163 74); }
848
+ .dark .cv-attachment-item--sent .cv-attachment-item__meta { color: rgb(74 222 128); }
849
+ .cv-attachment-item--skipped .cv-attachment-item__meta { color: rgb(180 83 9); }
850
+ .dark .cv-attachment-item--skipped .cv-attachment-item__meta { color: rgb(251 191 36); }
851
+ .cv-attachment-item__retry {
852
+ flex: none;
853
+ border: 0;
854
+ background: none;
855
+ padding: 0;
856
+ font-size: 0.6875rem;
857
+ text-decoration: underline;
858
+ color: rgb(37 99 235);
859
+ cursor: pointer;
860
+ }
861
+ .cv-attachment-item {
862
+ transition: opacity 200ms ease, transform 200ms ease;
863
+ opacity: 1;
864
+ transform: scale(1);
865
+ }
866
+ .cv-attachment-item--departing {
867
+ opacity: 0;
868
+ transform: scale(0.92);
869
+ }
870
+ @media (prefers-reduced-motion: reduce) {
871
+ .cv-attachment-item { transition: none; }
872
+ }
873
+
874
+ /* Esqueleto de linha — usado no painel de mensagens prontas e na tabela de cadastro (QR-47): nunca
875
+ "Carregando…" solto, e o conteúdo nunca aparece de uma vez. */
876
+ .cv-skeleton-line {
877
+ border-radius: 0.25rem;
878
+ background: linear-gradient(90deg, rgb(226 232 240) 25%, rgb(241 245 249) 50%, rgb(226 232 240) 75%);
879
+ background-size: 200% 100%;
880
+ animation: cv-skeleton-shimmer 1.4s ease-in-out infinite;
881
+ }
882
+ .dark .cv-skeleton-line {
883
+ background: linear-gradient(90deg, rgb(51 65 85) 25%, rgb(71 85 105) 50%, rgb(51 65 85) 75%);
884
+ background-size: 200% 100%;
885
+ }
886
+ @keyframes cv-skeleton-shimmer {
887
+ 0% { background-position: 200% 0; }
888
+ 100% { background-position: -200% 0; }
889
+ }
890
+ @media (prefers-reduced-motion: reduce) {
891
+ .cv-skeleton-line { animation: none; }
892
+ }
893
+ /* Larguras da linha de esqueleto da tabela de mensagens prontas: uma por coluna, porque colunas
894
+ diferentes leem melhor com proporções diferentes em vez de todas do mesmo tamanho. */
895
+ .cv-skeleton-line--title { width: 70%; height: 0.75rem; }
896
+ .cv-skeleton-line--shortcut { width: 50%; height: 0.75rem; }
897
+ .cv-skeleton-line--body { width: 90%; height: 0.75rem; }
898
+
899
+ /* Linha excluída sai com transição curta em vez de sumir de repente (QR-47). */
900
+ .cv-row-departing {
901
+ transition: opacity 200ms ease;
902
+ opacity: 0;
903
+ }
904
+ @media (prefers-reduced-motion: reduce) {
905
+ .cv-row-departing { transition: none; }
906
+ }
907
+
821
908
  /* Peças de listagem (documentos, mensagens): tabela com filtros, seleção em lote e paginação. */
822
909
  .cv-filter-count {
823
910
  display: inline-flex;