@adatechnology/conversations-ui 0.1.0-rc.8 → 0.1.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.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -678
- package/dist/index.d.ts +921 -17
- package/dist/index.js +3677 -678
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-73MW5HNT.js +0 -1717
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peças de listagem que toda tela tabular do pacote precisa ter iguais: cabeçalho ordenável,
|
|
3
|
+
* filtro de seleção múltipla, barra de ação em lote e paginação (regra `web.md` §7).
|
|
4
|
+
*
|
|
5
|
+
* Vivem aqui, e não dentro de cada tela, porque a alternativa já se provou pior: cada workspace
|
|
6
|
+
* recriava o seu, e o mesmo "limpar filtros" ficava em três lugares com três comportamentos.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, type ReactNode } from 'react'
|
|
10
|
+
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, X } from 'lucide-react'
|
|
11
|
+
|
|
12
|
+
import { cn } from '../lib/cn'
|
|
13
|
+
|
|
14
|
+
export type SortDirection = 'asc' | 'desc'
|
|
15
|
+
|
|
16
|
+
export interface SortableHeadProps {
|
|
17
|
+
readonly label: string
|
|
18
|
+
readonly field: string
|
|
19
|
+
readonly activeField: string
|
|
20
|
+
readonly direction: SortDirection
|
|
21
|
+
readonly onSort: (field: string) => void
|
|
22
|
+
readonly className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SortableHead({ label, field, activeField, direction, onSort, className }: SortableHeadProps) {
|
|
26
|
+
const isActive = activeField === field
|
|
27
|
+
const Icon = !isActive ? ArrowUpDown : direction === 'asc' ? ArrowUp : ArrowDown
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<th scope="col" className={cn('px-3 py-2 text-left text-xs font-medium', className)}>
|
|
31
|
+
<button
|
|
32
|
+
data-cv-tooltip={label}
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => onSort(field)}
|
|
35
|
+
aria-label={label}
|
|
36
|
+
className="inline-flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
|
|
37
|
+
>
|
|
38
|
+
{label}
|
|
39
|
+
<Icon size={12} className={isActive ? '' : 'opacity-40'} aria-hidden="true" />
|
|
40
|
+
</button>
|
|
41
|
+
</th>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FilterOption {
|
|
46
|
+
readonly value: string
|
|
47
|
+
readonly label: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MultiSelectFilterProps {
|
|
51
|
+
readonly label: string
|
|
52
|
+
readonly options: readonly FilterOption[]
|
|
53
|
+
readonly selected: readonly string[]
|
|
54
|
+
readonly onChange: (selected: readonly string[]) => void
|
|
55
|
+
readonly className?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function MultiSelectFilter({ label, options, selected, onChange, className }: MultiSelectFilterProps) {
|
|
59
|
+
const [open, setOpen] = useState(false)
|
|
60
|
+
|
|
61
|
+
function toggle(value: string): void {
|
|
62
|
+
onChange(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={cn('relative', className)}>
|
|
67
|
+
<button
|
|
68
|
+
data-cv-tooltip={label} aria-label={label}
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => setOpen((current) => !current)}
|
|
71
|
+
className="cv-header-action inline-flex items-center gap-1"
|
|
72
|
+
>
|
|
73
|
+
{label}
|
|
74
|
+
{selected.length > 0 ? <span className="cv-filter-count">{selected.length}</span> : null}
|
|
75
|
+
<ChevronDown size={12} className={open ? 'rotate-180 transition-transform' : 'transition-transform'} aria-hidden="true" />
|
|
76
|
+
</button>
|
|
77
|
+
|
|
78
|
+
{open ? (
|
|
79
|
+
<>
|
|
80
|
+
{/* Sem a camada de fundo o dropdown só fechava clicando de novo no botão, e ficava aberto
|
|
81
|
+
por cima da tabela enquanto o usuário tentava clicar numa linha. */}
|
|
82
|
+
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
|
83
|
+
<div className="cv-filter-menu">
|
|
84
|
+
{options.map((option) => (
|
|
85
|
+
<label key={option.value} className="cv-filter-option">
|
|
86
|
+
<input
|
|
87
|
+
type="checkbox"
|
|
88
|
+
checked={selected.includes(option.value)}
|
|
89
|
+
onChange={() => toggle(option.value)}
|
|
90
|
+
className="cursor-pointer rounded border-gray-300 text-blue-600 dark:border-gray-600"
|
|
91
|
+
/>
|
|
92
|
+
{option.label}
|
|
93
|
+
</label>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</>
|
|
97
|
+
) : null}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface BulkActionBarProps {
|
|
103
|
+
readonly selectedCount: number
|
|
104
|
+
readonly selectedLabel: (count: number) => string
|
|
105
|
+
readonly clearLabel: string
|
|
106
|
+
readonly onClear: () => void
|
|
107
|
+
readonly children?: ReactNode
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function BulkActionBar({ selectedCount, selectedLabel, clearLabel, onClear, children }: BulkActionBarProps) {
|
|
111
|
+
if (selectedCount === 0) return null
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="cv-bulk-bar" role="toolbar" aria-label={selectedLabel(selectedCount)}>
|
|
115
|
+
<span className="text-xs font-medium">{selectedLabel(selectedCount)}</span>
|
|
116
|
+
{children}
|
|
117
|
+
<button data-cv-tooltip={clearLabel} aria-label={clearLabel} type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
|
|
118
|
+
<X size={12} aria-hidden="true" />
|
|
119
|
+
{clearLabel}
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ListingPaginationProps {
|
|
126
|
+
readonly page: number
|
|
127
|
+
readonly total: number
|
|
128
|
+
readonly perPage: number
|
|
129
|
+
readonly perPageOptions?: readonly number[]
|
|
130
|
+
readonly onPageChange: (page: number) => void
|
|
131
|
+
readonly onPerPageChange?: (perPage: number) => void
|
|
132
|
+
readonly labels: {
|
|
133
|
+
readonly show: string
|
|
134
|
+
readonly perPage: string
|
|
135
|
+
readonly total: (count: number) => string
|
|
136
|
+
readonly page: (current: number, last: number) => string
|
|
137
|
+
readonly previous: string
|
|
138
|
+
readonly next: string
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function ListingPagination({
|
|
143
|
+
page,
|
|
144
|
+
total,
|
|
145
|
+
perPage,
|
|
146
|
+
perPageOptions,
|
|
147
|
+
onPageChange,
|
|
148
|
+
onPerPageChange,
|
|
149
|
+
labels,
|
|
150
|
+
}: ListingPaginationProps) {
|
|
151
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage))
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className="cv-listing-pagination">
|
|
155
|
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
|
156
|
+
{onPerPageChange && perPageOptions ? (
|
|
157
|
+
<>
|
|
158
|
+
<span>{labels.show}</span>
|
|
159
|
+
<select
|
|
160
|
+
value={perPage}
|
|
161
|
+
onChange={(event) => onPerPageChange(Number(event.target.value))}
|
|
162
|
+
aria-label={labels.perPage}
|
|
163
|
+
className="cv-listing-perpage"
|
|
164
|
+
>
|
|
165
|
+
{perPageOptions.map((option) => (
|
|
166
|
+
<option key={option} value={option}>
|
|
167
|
+
{option}
|
|
168
|
+
</option>
|
|
169
|
+
))}
|
|
170
|
+
</select>
|
|
171
|
+
<span>{labels.perPage}</span>
|
|
172
|
+
</>
|
|
173
|
+
) : null}
|
|
174
|
+
<span className="ml-1">{labels.total(total)}</span>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div className="flex items-center gap-2">
|
|
178
|
+
<button
|
|
179
|
+
data-cv-tooltip={labels.previous}
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={() => onPageChange(page - 1)}
|
|
182
|
+
disabled={page <= 1}
|
|
183
|
+
aria-label={labels.previous}
|
|
184
|
+
className="cv-header-icon disabled:opacity-40"
|
|
185
|
+
>
|
|
186
|
+
‹
|
|
187
|
+
</button>
|
|
188
|
+
<span className="text-xs text-gray-500">{labels.page(page, lastPage)}</span>
|
|
189
|
+
<button
|
|
190
|
+
data-cv-tooltip={labels.next}
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => onPageChange(page + 1)}
|
|
193
|
+
disabled={page >= lastPage}
|
|
194
|
+
aria-label={labels.next}
|
|
195
|
+
className="cv-header-icon disabled:opacity-40"
|
|
196
|
+
>
|
|
197
|
+
›
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rótulos dos controles de paginação. Os pagers da inbox, do painel de documentos e da biblioteca
|
|
3
|
+
* são o mesmo controle repetido: o texto mora aqui para os três dizerem a mesma coisa.
|
|
4
|
+
*/
|
|
5
|
+
export const PAGINATION_LABELS = {
|
|
6
|
+
first: 'Primeira página',
|
|
7
|
+
previous: 'Página anterior',
|
|
8
|
+
next: 'Próxima página',
|
|
9
|
+
last: 'Última página',
|
|
10
|
+
} as const
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Visão lado-cliente: você digita como se fosse o cliente
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Visão lado-cliente: você digita como se fosse o cliente e vê o bot responder. A mensagem sai pelo
|
|
3
|
+
* transporte real do canal — webhook assinado no WhatsApp, rota pública do widget no chat do site —,
|
|
4
|
+
* então o que roda aqui é o mesmo caminho de staging e produção: parser, motor de conversa, fluxo.
|
|
5
|
+
*
|
|
6
|
+
* O canal entra por `client` (a porta `ConversationSimulatorClient`), não por condicional aqui
|
|
7
|
+
* dentro: esta tela não sabe qual canal está simulando, e é o que permite o painel ficar no mesmo
|
|
8
|
+
* lugar da conversa para todos eles.
|
|
5
9
|
*
|
|
6
10
|
* É o layout de conversa de verdade (wallpaper, divisor de data, agrupamento de bolhas), não uma
|
|
7
11
|
* casca de teste: o preview serve para julgar copy e fluxo, e isso só funciona se o que se vê
|
|
@@ -19,11 +23,24 @@ import { MessageBubble } from '../MessageBubble'
|
|
|
19
23
|
import { MessageComposer } from '../MessageComposer'
|
|
20
24
|
import { DateDivider } from '../DateDivider'
|
|
21
25
|
import { ConversationWallpaper } from '../Wallpaper'
|
|
22
|
-
import type { PreviewWebhookClient
|
|
23
|
-
import {
|
|
26
|
+
import type { PreviewWebhookClient } from './createPreviewWebhookClient'
|
|
27
|
+
import {
|
|
28
|
+
acceptsMediaKind,
|
|
29
|
+
isConversationSimulatorClient,
|
|
30
|
+
mediaKindOf,
|
|
31
|
+
SIMULATOR_FILE_MEDIA_KINDS,
|
|
32
|
+
toConversationSimulatorClient,
|
|
33
|
+
type ConversationSimulatorClient,
|
|
34
|
+
type SimulatorMediaKind,
|
|
35
|
+
} from './ConversationSimulatorClient'
|
|
36
|
+
import { AudioRecorderButton } from '../AudioRecorderButton'
|
|
24
37
|
|
|
25
38
|
export type ConversationPreviewProps = {
|
|
26
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Transporte do canal. `PreviewWebhookClient` continua aceito — é o caminho WhatsApp de antes
|
|
41
|
+
* desta porta, adaptado aqui dentro para não obrigar host nenhum a mudar de chamada.
|
|
42
|
+
*/
|
|
43
|
+
client: ConversationSimulatorClient | PreviewWebhookClient
|
|
27
44
|
sse: SSEProvider
|
|
28
45
|
conversationId: string
|
|
29
46
|
loadMessages: (conversationId: string) => Promise<MessagePayload[]>
|
|
@@ -35,10 +52,9 @@ export type ConversationPreviewProps = {
|
|
|
35
52
|
*/
|
|
36
53
|
pollIntervalMs?: number
|
|
37
54
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* botão que não existe do que um que falha ao ser tocado.
|
|
55
|
+
* Destino alternativo do upload, no canal que sobe a mídia antes de citá-la (o caminho da Meta
|
|
56
|
+
* entrega mídia por `id`). Sem isto, usa o do próprio `client`. Canal que manda os bytes direto
|
|
57
|
+
* ignora esta prop: quem decide referência × bytes é o adaptador do canal.
|
|
42
58
|
*/
|
|
43
59
|
uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
44
60
|
}
|
|
@@ -49,12 +65,9 @@ export type PreviewUploadedMedia = {
|
|
|
49
65
|
readonly filename?: string
|
|
50
66
|
}
|
|
51
67
|
|
|
52
|
-
/**
|
|
53
|
-
export function mediaTypeOf(mimeType: string):
|
|
54
|
-
|
|
55
|
-
if (mimeType.startsWith('video/')) return 'video'
|
|
56
|
-
if (mimeType.startsWith('audio/')) return 'audio'
|
|
57
|
-
return 'document'
|
|
68
|
+
/** @deprecated Use `mediaKindOf`, que não nomeia canal. Mantido para quem já importa. */
|
|
69
|
+
export function mediaTypeOf(mimeType: string): SimulatorMediaKind {
|
|
70
|
+
return mediaKindOf(mimeType)
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
// Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
|
|
@@ -128,6 +141,7 @@ export function ConversationPreview({
|
|
|
128
141
|
const [messages, setMessages] = useState<MessagePayload[]>([])
|
|
129
142
|
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
130
143
|
const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
|
|
144
|
+
const [isRecording, setIsRecording] = useState(false)
|
|
131
145
|
// Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
|
|
132
146
|
// ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
|
|
133
147
|
// indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
|
|
@@ -137,6 +151,14 @@ export function ConversationPreview({
|
|
|
137
151
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
138
152
|
loadMessagesRef.current = loadMessages
|
|
139
153
|
|
|
154
|
+
const simulator = useMemo(
|
|
155
|
+
() =>
|
|
156
|
+
isConversationSimulatorClient(client)
|
|
157
|
+
? client
|
|
158
|
+
: toConversationSimulatorClient({ client, ...(uploadMedia ? { uploadMedia } : {}) }),
|
|
159
|
+
[client, uploadMedia],
|
|
160
|
+
)
|
|
161
|
+
|
|
140
162
|
const refresh = useCallback(async (): Promise<void> => {
|
|
141
163
|
try {
|
|
142
164
|
const loaded = await loadMessagesRef.current(conversationId)
|
|
@@ -187,7 +209,10 @@ export function ConversationPreview({
|
|
|
187
209
|
}, [pollIntervalMs, refresh])
|
|
188
210
|
|
|
189
211
|
useEffect(() => {
|
|
190
|
-
|
|
212
|
+
// `block: 'nearest'` e não o padrão ('start'): o padrão alinha o elemento ao topo da área
|
|
213
|
+
// visível MAIS PRÓXIMA que role — e quando o container do preview não tem altura limitada, essa
|
|
214
|
+
// área é a PÁGINA. O efeito era a tela inteira saltar para baixo ao abrir/usar o simulador.
|
|
215
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
191
216
|
}, [messages])
|
|
192
217
|
|
|
193
218
|
const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
|
|
@@ -202,7 +227,7 @@ export function ConversationPreview({
|
|
|
202
227
|
async function handleSend(text: string): Promise<void> {
|
|
203
228
|
setFailure(undefined)
|
|
204
229
|
try {
|
|
205
|
-
await
|
|
230
|
+
await simulator.sendText(text)
|
|
206
231
|
setPendingLocal((current) => [
|
|
207
232
|
...current,
|
|
208
233
|
{
|
|
@@ -230,28 +255,38 @@ export function ConversationPreview({
|
|
|
230
255
|
|
|
231
256
|
async function handleInteractiveSelect(selection: InteractiveSelection): Promise<void> {
|
|
232
257
|
setFailure(undefined)
|
|
233
|
-
const reply = { id: selection.option.id, title: selection.option.title }
|
|
234
258
|
try {
|
|
235
|
-
//
|
|
236
|
-
// roteador do fluxo lê campos distintos
|
|
237
|
-
//
|
|
238
|
-
|
|
259
|
+
// A seleção viaja inteira (botão × lista) porque a forma de fio é do canal: a Meta separa
|
|
260
|
+
// `button_reply` de `list_reply` e o roteador do fluxo lê campos distintos, enquanto o chat do
|
|
261
|
+
// site manda o rótulo como texto. Decidir isso aqui faria o menu responder no simulador e
|
|
262
|
+
// falhar no aparelho do cliente.
|
|
263
|
+
await simulator.sendReply(selection)
|
|
239
264
|
await refreshWithFollowUps()
|
|
240
265
|
} catch (error) {
|
|
241
266
|
setFailure(error instanceof Error ? error.message : 'Falha ao entregar a resposta no webhook.')
|
|
242
267
|
}
|
|
243
268
|
}
|
|
244
269
|
|
|
270
|
+
/**
|
|
271
|
+
* O transporte do canal diz se aceita mídia do cliente; a tela só pergunta.
|
|
272
|
+
*
|
|
273
|
+
* Antes isto era `uploadMedia` puro, e o microfone só aparecia no produto que lembrasse de montar
|
|
274
|
+
* o upload — de onde veio a divergência entre dois simuladores da mesma casa. Agora quem responde
|
|
275
|
+
* é o adaptador: no WhatsApp ele precisa de um destino de upload, no chat do site manda os bytes.
|
|
276
|
+
*/
|
|
277
|
+
const sendMedia = simulator.sendMedia
|
|
278
|
+
const canAttachFile = SIMULATOR_FILE_MEDIA_KINDS.some((kind) => acceptsMediaKind(simulator, kind))
|
|
279
|
+
const canRecordAudio = acceptsMediaKind(simulator, 'audio')
|
|
280
|
+
|
|
245
281
|
async function handleAttach(file: File): Promise<void> {
|
|
246
|
-
if (!
|
|
282
|
+
if (!sendMedia) return
|
|
247
283
|
setFailure(undefined)
|
|
248
284
|
try {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
filename: uploaded.filename ?? file.name,
|
|
285
|
+
await sendMedia({
|
|
286
|
+
mediaKind: mediaKindOf(file.type),
|
|
287
|
+
file,
|
|
288
|
+
mimeType: file.type,
|
|
289
|
+
filename: file.name,
|
|
255
290
|
})
|
|
256
291
|
await refreshWithFollowUps()
|
|
257
292
|
} catch (error) {
|
|
@@ -297,21 +332,25 @@ export function ConversationPreview({
|
|
|
297
332
|
</p>
|
|
298
333
|
) : null}
|
|
299
334
|
|
|
300
|
-
<
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
335
|
+
<MessageComposer
|
|
336
|
+
onSend={(text) => void handleSend(text)}
|
|
337
|
+
onAttach={canAttachFile ? (file) => void handleAttach(file) : undefined}
|
|
338
|
+
/* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
|
|
339
|
+
que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
|
|
340
|
+
microfone está quebrado. */
|
|
341
|
+
placeholder={
|
|
342
|
+
isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
|
|
343
|
+
}
|
|
344
|
+
idleAction={
|
|
345
|
+
canRecordAudio ? (
|
|
346
|
+
<AudioRecorderButton
|
|
347
|
+
onRecorded={(file) => void handleAttach(file)}
|
|
348
|
+
onFailure={(message) => setFailure(message)}
|
|
349
|
+
onRecordingChange={setIsRecording}
|
|
350
|
+
/>
|
|
351
|
+
) : undefined
|
|
352
|
+
}
|
|
353
|
+
/>
|
|
315
354
|
</div>
|
|
316
355
|
)
|
|
317
356
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ConversationSimulatorPanel, type ConversationSimulatorPanelProps } from './ConversationSimulatorPanel'
|
|
5
|
+
import { createMockSSEProvider } from './createMockSSEProvider'
|
|
6
|
+
import { createPreviewStore } from './previewStore'
|
|
7
|
+
|
|
8
|
+
function markupOf(overrides: Partial<ConversationSimulatorPanelProps> = {}): string {
|
|
9
|
+
const store = createPreviewStore({ conversations: [], messages: {} })
|
|
10
|
+
|
|
11
|
+
return renderToStaticMarkup(
|
|
12
|
+
<ConversationSimulatorPanel
|
|
13
|
+
conversationId="5511900000042"
|
|
14
|
+
onClose={() => {}}
|
|
15
|
+
client={{ sendText: async () => {}, sendInteractiveReply: async () => {} } as never}
|
|
16
|
+
sse={createMockSSEProvider({ store })}
|
|
17
|
+
loadMessages={async () => []}
|
|
18
|
+
{...overrides}
|
|
19
|
+
/>,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('ConversationSimulatorPanel', () => {
|
|
24
|
+
it('mostra o telefone formatado pelo host, e não o id cru', () => {
|
|
25
|
+
expect(markupOf({ displayNumber: '+55 (11) 90000-0042' })).toContain('+55 (11) 90000-0042')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('cai no id da conversa quando o host não formata', () => {
|
|
29
|
+
expect(markupOf()).toContain('5511900000042')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('avisa para onde a mensagem vai, para ninguém achar que é conversa de mentira', () => {
|
|
33
|
+
expect(markupOf()).toContain('entrega no webhook real')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('dá nome acessível ao botão de fechar, que só tem ícone', () => {
|
|
37
|
+
expect(markupOf()).toContain('aria-label="Fechar simulador"')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('aceita rótulos parciais do host sem exigir o conjunto inteiro', () => {
|
|
41
|
+
const markup = markupOf({ labels: { title: 'Testar fluxo' } })
|
|
42
|
+
|
|
43
|
+
expect(markup).toContain('Testar fluxo')
|
|
44
|
+
// O que não foi sobrescrito continua no default.
|
|
45
|
+
expect(markup).toContain('aria-label="Fechar simulador"')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renderiza ação extra do host no cabeçalho', () => {
|
|
49
|
+
expect(markupOf({ headerActions: <button type="button">Rodar roteiro</button> })).toContain('Rodar roteiro')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('nomeia o próprio aside, para o leitor de tela distinguir do transcript ao lado', () => {
|
|
53
|
+
expect(markupOf()).toContain('aria-label="Simulador do cliente"')
|
|
54
|
+
})
|
|
55
|
+
})
|