@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.20
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/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-OIDAIVCH.js +1985 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +729 -136
- package/dist/index.js +1418 -1104
- package/dist/preview/index.d.ts +373 -0
- package/dist/preview/index.js +1247 -0
- package/dist/styles.css +198 -0
- package/dist/types-O7kMP1Yn.d.ts +416 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +127 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +64 -21
- package/src/MessageBubble.tsx +54 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +146 -19
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +380 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +54 -6
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +17 -14
- package/src/flows/FlowMapNode.tsx +3 -1
- package/src/flows/FlowNodeCard.tsx +22 -4
- package/src/flows/FlowNodePanel.tsx +132 -35
- package/src/flows/FlowPalette.tsx +6 -2
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/flowGraph.ts +5 -5
- package/src/flows/labels.ts +5 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/index.ts +129 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +325 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +88 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +131 -0
- package/src/preview/index.ts +61 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +163 -11
- package/src/quickReply.test.ts +58 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +46 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gravação de áudio no simulador, pelo microfone do próprio navegador.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
|
|
5
|
+
* poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
|
|
6
|
+
*
|
|
7
|
+
* O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
|
|
8
|
+
* hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
12
|
+
|
|
13
|
+
import { AudioPlayer } from './AudioPlayer'
|
|
14
|
+
|
|
15
|
+
export interface AudioRecorderButtonLabels {
|
|
16
|
+
start: string
|
|
17
|
+
stop: string
|
|
18
|
+
unsupported: string
|
|
19
|
+
denied: string
|
|
20
|
+
review: string
|
|
21
|
+
send: string
|
|
22
|
+
discard: string
|
|
23
|
+
empty: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
|
|
27
|
+
start: 'Gravar áudio',
|
|
28
|
+
stop: 'Parar gravação',
|
|
29
|
+
unsupported: 'Este navegador não grava áudio.',
|
|
30
|
+
denied: 'Sem permissão para usar o microfone.',
|
|
31
|
+
review: 'Ouça antes de enviar',
|
|
32
|
+
send: 'Enviar áudio',
|
|
33
|
+
discard: 'Descartar áudio',
|
|
34
|
+
empty: 'Nada foi captado pelo microfone.',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AudioRecorderButtonProps {
|
|
38
|
+
onRecorded: (file: File) => void | Promise<void>
|
|
39
|
+
onFailure?: (message: string) => void
|
|
40
|
+
/**
|
|
41
|
+
* Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
|
|
42
|
+
* envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
|
|
43
|
+
* que o microfone está quebrado.
|
|
44
|
+
*/
|
|
45
|
+
onRecordingChange?: (isRecording: boolean) => void
|
|
46
|
+
/**
|
|
47
|
+
* Abre uma etapa de revisão quando a gravação para: o áudio toca ali mesmo e só sai depois de
|
|
48
|
+
* confirmado. Ligado por padrão — voz é o único anexo que quem envia não viu antes de mandar, e
|
|
49
|
+
* sem ouvir não há como saber se o microfone captou alguma coisa. Desligar volta ao envio direto.
|
|
50
|
+
*/
|
|
51
|
+
reviewBeforeSend?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
|
|
54
|
+
* tem. Produto com limite próprio sobrescreve.
|
|
55
|
+
*/
|
|
56
|
+
maxDurationMilliseconds?: number
|
|
57
|
+
labels?: Partial<AudioRecorderButtonLabels>
|
|
58
|
+
disabled?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
|
|
63
|
+
* 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
|
|
64
|
+
* existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
|
|
70
|
+
* saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
|
|
71
|
+
* do envio que o formato é inválido é pior do que gravar já no formato certo.
|
|
72
|
+
*/
|
|
73
|
+
const RECORDING_FORMATS = [
|
|
74
|
+
{ mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
|
|
75
|
+
{ mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
|
|
76
|
+
{ mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
|
|
77
|
+
] as const
|
|
78
|
+
|
|
79
|
+
export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
|
|
80
|
+
|
|
81
|
+
/** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
|
|
82
|
+
export function resolveRecordingFormat(): RecordingFormat | undefined {
|
|
83
|
+
if (typeof MediaRecorder === 'undefined') return undefined
|
|
84
|
+
// `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
|
|
85
|
+
if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
|
|
86
|
+
return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type PendingRecording = { file: File; objectURL: string }
|
|
90
|
+
|
|
91
|
+
export function AudioRecorderButton({
|
|
92
|
+
onRecorded,
|
|
93
|
+
onFailure,
|
|
94
|
+
onRecordingChange,
|
|
95
|
+
reviewBeforeSend = true,
|
|
96
|
+
maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
97
|
+
labels,
|
|
98
|
+
disabled,
|
|
99
|
+
}: AudioRecorderButtonProps) {
|
|
100
|
+
const labelOf = (key: keyof AudioRecorderButtonLabels): string =>
|
|
101
|
+
labels?.[key] ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS[key]
|
|
102
|
+
const [isRecording, setIsRecording] = useState(false)
|
|
103
|
+
const [pending, setPending] = useState<PendingRecording | undefined>(undefined)
|
|
104
|
+
const recorderRef = useRef<MediaRecorder | null>(null)
|
|
105
|
+
const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
106
|
+
|
|
107
|
+
// O `objectURL` é um recurso do documento, não do React: sem revogar, cada gravação descartada
|
|
108
|
+
// deixa o blob inteiro preso na memória da aba até um reload.
|
|
109
|
+
const discard = useCallback(() => {
|
|
110
|
+
setPending((current) => {
|
|
111
|
+
if (current) URL.revokeObjectURL(current.objectURL)
|
|
112
|
+
return undefined
|
|
113
|
+
})
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
useEffect(() => discard, [discard])
|
|
117
|
+
|
|
118
|
+
const confirm = useCallback(() => {
|
|
119
|
+
if (!pending) return
|
|
120
|
+
void onRecorded(pending.file)
|
|
121
|
+
discard()
|
|
122
|
+
}, [discard, onRecorded, pending])
|
|
123
|
+
|
|
124
|
+
const stop = useCallback(() => {
|
|
125
|
+
recorderRef.current?.stop()
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
const start = useCallback(async () => {
|
|
129
|
+
const format = resolveRecordingFormat()
|
|
130
|
+
if (!format || !navigator.mediaDevices?.getUserMedia) {
|
|
131
|
+
onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
137
|
+
const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
|
|
138
|
+
const chunks: Blob[] = []
|
|
139
|
+
|
|
140
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
141
|
+
if (event.data.size > 0) chunks.push(event.data)
|
|
142
|
+
})
|
|
143
|
+
recorder.addEventListener('stop', () => {
|
|
144
|
+
// Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
|
|
145
|
+
// aceso depois do envio, e o operador acha que o simulador continua ouvindo.
|
|
146
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
147
|
+
clearTimeout(autoStopRef.current)
|
|
148
|
+
setIsRecording(false)
|
|
149
|
+
onRecordingChange?.(false)
|
|
150
|
+
recorderRef.current = null
|
|
151
|
+
// O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
|
|
152
|
+
// gravador, mas quem valida upload compara com `audio/ogg` puro.
|
|
153
|
+
const blob = new Blob(chunks, { type: format.uploadMimeType })
|
|
154
|
+
const file = new File([blob], `audio-${Date.now()}.${format.extension}`, {
|
|
155
|
+
type: format.uploadMimeType,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Gravação vazia não vira anexo: microfone mudo ou permissão revogada no meio produzem um
|
|
159
|
+
// blob de zero byte, e mandá-lo adiante só falha lá na frente, sem dizer por quê.
|
|
160
|
+
if (blob.size === 0) {
|
|
161
|
+
onFailure?.(labelOf('empty'))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!reviewBeforeSend) {
|
|
166
|
+
void onRecorded(file)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setPending({ file, objectURL: URL.createObjectURL(blob) })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
recorderRef.current = recorder
|
|
174
|
+
recorder.start()
|
|
175
|
+
autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
|
|
176
|
+
setIsRecording(true)
|
|
177
|
+
onRecordingChange?.(true)
|
|
178
|
+
} catch {
|
|
179
|
+
onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
|
|
180
|
+
}
|
|
181
|
+
// `labelOf` lê `labels` a cada render e não entra aqui; o que importa para recriar o gravador
|
|
182
|
+
// são o teto de duração, o destino da gravação e se há etapa de revisão.
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
|
+
}, [maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange, reviewBeforeSend])
|
|
185
|
+
|
|
186
|
+
const toggleLabel = isRecording ? labelOf('stop') : labelOf('start')
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
/* O painel de revisão flutua sobre o botão em vez de ocupar espaço na barra: o microfone mora
|
|
190
|
+
na caixa do botão de enviar, e empurrar o composer para cima a cada gravação faria a
|
|
191
|
+
conversa saltar. */
|
|
192
|
+
<div className="relative flex-shrink-0">
|
|
193
|
+
{pending && (
|
|
194
|
+
<div
|
|
195
|
+
role="group"
|
|
196
|
+
aria-label={labelOf('review')}
|
|
197
|
+
/* Largura presa à viewport, não ao conteúdo: ancorado à direita do microfone, qualquer
|
|
198
|
+
largura fixa maior que a tela sangra para fora no celular. `flex-wrap` é a segunda
|
|
199
|
+
rede — se o player e os dois botões não couberem lado a lado, os botões descem. */
|
|
200
|
+
className="absolute bottom-full right-0 z-20 mb-2 flex w-[calc(100vw-2rem)] max-w-[20rem] flex-wrap items-center justify-end gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
|
201
|
+
>
|
|
202
|
+
<div className="min-w-0 flex-1">
|
|
203
|
+
<AudioPlayer src={pending.objectURL} />
|
|
204
|
+
</div>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={discard}
|
|
208
|
+
title={labelOf('discard')}
|
|
209
|
+
aria-label={labelOf('discard')}
|
|
210
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:hover:bg-gray-700"
|
|
211
|
+
>
|
|
212
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
|
213
|
+
</button>
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={confirm}
|
|
217
|
+
title={labelOf('send')}
|
|
218
|
+
aria-label={labelOf('send')}
|
|
219
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
|
|
220
|
+
>
|
|
221
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
disabled={disabled || pending !== undefined}
|
|
228
|
+
onClick={() => (isRecording ? stop() : void start())}
|
|
229
|
+
title={toggleLabel}
|
|
230
|
+
aria-label={toggleLabel}
|
|
231
|
+
aria-pressed={isRecording}
|
|
232
|
+
/* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
|
|
233
|
+
vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
|
|
234
|
+
className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${
|
|
235
|
+
isRecording
|
|
236
|
+
? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
|
|
237
|
+
: 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
238
|
+
}`}
|
|
239
|
+
>
|
|
240
|
+
{isRecording ? (
|
|
241
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
|
|
242
|
+
) : (
|
|
243
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
|
|
244
|
+
)}
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
package/src/Avatar.tsx
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
export interface AvatarLabels {
|
|
2
|
+
/** Lido por leitor de tela quando não há nome nem imagem — a silhueta genérica. */
|
|
3
|
+
unnamedContact: string
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
export interface AvatarProps {
|
|
2
7
|
name?: string | null
|
|
3
8
|
avatarUrl?: string
|
|
4
9
|
size?: 'sm' | 'md' | 'lg'
|
|
5
10
|
className?: string
|
|
11
|
+
labels?: Partial<AvatarLabels>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_AVATAR_LABELS: AvatarLabels = {
|
|
15
|
+
unnamedContact: 'Contato sem nome',
|
|
6
16
|
}
|
|
7
17
|
|
|
8
18
|
const sizeClasses = {
|
|
@@ -36,8 +46,9 @@ function getBgColor(name: string | undefined | null): string {
|
|
|
36
46
|
|
|
37
47
|
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/Avatar.tsx —
|
|
38
48
|
// mesmas iniciais (primeira+última letra), mesmo hash de cor, mesmas classes de tamanho.
|
|
39
|
-
export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarProps) {
|
|
49
|
+
export function Avatar({ name, avatarUrl, size = 'md', className = '', labels }: AvatarProps) {
|
|
40
50
|
const sizeClass = sizeClasses[size]
|
|
51
|
+
const unnamedContactLabel = labels?.unnamedContact ?? DEFAULT_AVATAR_LABELS.unnamedContact
|
|
41
52
|
|
|
42
53
|
if (avatarUrl) {
|
|
43
54
|
return (
|
|
@@ -49,15 +60,30 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarP
|
|
|
49
60
|
)
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
const initials = getInitials(name)
|
|
53
63
|
const bgColor = getBgColor(name)
|
|
54
64
|
|
|
65
|
+
// Sem nome e sem imagem, a silhueta genérica. Iniciais exigem nome — derivá-las de um telefone
|
|
66
|
+
// produz rótulo sem significado ("+9"), que é pior que assumir o anonimato.
|
|
67
|
+
if (!name) {
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white flex-shrink-0 ${className}`}
|
|
71
|
+
role="img"
|
|
72
|
+
aria-label={unnamedContactLabel}
|
|
73
|
+
>
|
|
74
|
+
<svg viewBox="0 0 24 24" fill="currentColor" className="h-[60%] w-[60%]" aria-hidden>
|
|
75
|
+
<path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5Z" />
|
|
76
|
+
</svg>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
return (
|
|
56
82
|
<div
|
|
57
83
|
className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 ${className}`}
|
|
58
|
-
title={name
|
|
84
|
+
title={name}
|
|
59
85
|
>
|
|
60
|
-
{
|
|
86
|
+
{getInitials(name)}
|
|
61
87
|
</div>
|
|
62
88
|
)
|
|
63
89
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marca visual de cada canal.
|
|
3
|
+
*
|
|
4
|
+
* SVG em vez de emoji porque emoji não tem logo de marca: 💬 e 📨 não distinguem WhatsApp de
|
|
5
|
+
* Messenger para quem bate o olho numa fila de conversas. São marcas **simplificadas** desenhadas
|
|
6
|
+
* com primitivas — reconhecíveis pela forma e pela cor oficial, sem reproduzir o logotipo original.
|
|
7
|
+
*
|
|
8
|
+
* `currentColor` não serve aqui: a cor faz parte da identificação do canal.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CONVERSATION_CHANNEL, capabilitiesOf, type ConversationChannel } from './conversationChannel'
|
|
12
|
+
|
|
13
|
+
export const CHANNEL_BRAND_COLOR: Readonly<Record<ConversationChannel, string>> = {
|
|
14
|
+
[CONVERSATION_CHANNEL.WHATSAPP]: '#25D366',
|
|
15
|
+
[CONVERSATION_CHANNEL.MESSENGER]: '#0084FF',
|
|
16
|
+
[CONVERSATION_CHANNEL.INSTAGRAM]: '#E4405F',
|
|
17
|
+
[CONVERSATION_CHANNEL.WEBCHAT]: '#64748B',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChannelIconProps {
|
|
21
|
+
channel?: ConversationChannel | undefined
|
|
22
|
+
size?: number
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Glyph({ channel }: { channel: ConversationChannel }) {
|
|
27
|
+
const color = CHANNEL_BRAND_COLOR[channel]
|
|
28
|
+
|
|
29
|
+
if (channel === CONVERSATION_CHANNEL.WHATSAPP) {
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<circle cx="12" cy="12" r="11" fill={color} />
|
|
33
|
+
{/* Handset estilizado, o traço que identifica a marca à distância. */}
|
|
34
|
+
<path
|
|
35
|
+
d="M8.6 7.2c.3-.1.7 0 .9.3l1 1.6c.2.3.1.7-.1.9l-.7.7c.6 1.3 1.6 2.3 2.9 2.9l.7-.7c.2-.2.6-.3.9-.1l1.6 1c.3.2.4.6.3.9-.4 1-1.4 1.6-2.4 1.4-3-.5-5.4-2.9-5.9-5.9-.2-1 .4-2 1.4-2.4Z"
|
|
36
|
+
fill="#fff"
|
|
37
|
+
/>
|
|
38
|
+
</>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (channel === CONVERSATION_CHANNEL.MESSENGER) {
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<circle cx="12" cy="12" r="11" fill={color} />
|
|
46
|
+
{/* Raio do Messenger. */}
|
|
47
|
+
<path d="M6.5 15.5 11 10.8l2.4 2.4L17.5 9l-4.6 4.9-2.4-2.4-4 4Z" fill="#fff" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" />
|
|
48
|
+
</>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (channel === CONVERSATION_CHANNEL.INSTAGRAM) {
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<rect x="2" y="2" width="20" height="20" rx="6" fill={color} />
|
|
56
|
+
<rect x="6.5" y="6.5" width="11" height="11" rx="3.5" fill="none" stroke="#fff" strokeWidth="1.6" />
|
|
57
|
+
<circle cx="12" cy="12" r="2.6" fill="none" stroke="#fff" strokeWidth="1.6" />
|
|
58
|
+
<circle cx="16.4" cy="7.6" r="1" fill="#fff" />
|
|
59
|
+
</>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<circle cx="12" cy="12" r="11" fill={color} />
|
|
66
|
+
<path d="M6.5 8.5h11v7h-5.5L8.5 18v-2.5h-2v-7Z" fill="#fff" />
|
|
67
|
+
</>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ChannelIcon({ channel, size = 14, className = '' }: ChannelIconProps) {
|
|
72
|
+
const resolved: ConversationChannel = channel ?? CONVERSATION_CHANNEL.WHATSAPP
|
|
73
|
+
const { label } = capabilitiesOf(resolved)
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<svg
|
|
77
|
+
viewBox="0 0 24 24"
|
|
78
|
+
width={size}
|
|
79
|
+
height={size}
|
|
80
|
+
className={`inline-block shrink-0 ${className}`.trim()}
|
|
81
|
+
role="img"
|
|
82
|
+
aria-label={label}
|
|
83
|
+
>
|
|
84
|
+
<Glyph channel={resolved} />
|
|
85
|
+
</svg>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Suas Seleções": o que o bot já coletou na conversa. Para o atendente que assume no meio, é a
|
|
3
|
+
* diferença entre ler o transcript inteiro e ver o estado em duas linhas.
|
|
4
|
+
*
|
|
5
|
+
* O pacote não interpreta o contexto — ele é `Record<string, unknown>` e cada produto nomeia as
|
|
6
|
+
* próprias chaves. O host traduz para `entries`; aqui só se decide como mostrar.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
|
|
11
|
+
import { cn } from './lib/cn'
|
|
12
|
+
import { useIsNarrow } from './useIsNarrow'
|
|
13
|
+
|
|
14
|
+
export interface ConversationContextEntry {
|
|
15
|
+
key: string
|
|
16
|
+
label: string
|
|
17
|
+
value?: string | undefined
|
|
18
|
+
icon?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ConversationContextPanelLabels {
|
|
22
|
+
title: string
|
|
23
|
+
empty: string
|
|
24
|
+
collapse: string
|
|
25
|
+
expand: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
|
|
29
|
+
title: '📋 Suas Seleções',
|
|
30
|
+
empty: 'Nada coletado ainda nesta conversa.',
|
|
31
|
+
collapse: 'fechar',
|
|
32
|
+
expand: 'abrir',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ConversationContextPanelClassNames {
|
|
36
|
+
root: string
|
|
37
|
+
toggle: string
|
|
38
|
+
counter: string
|
|
39
|
+
body: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ConversationContextPanelProps {
|
|
43
|
+
entries: readonly ConversationContextEntry[]
|
|
44
|
+
/**
|
|
45
|
+
* Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
|
|
46
|
+
*
|
|
47
|
+
* Existe porque "abre sozinho" nem sempre é o que o produto quer: com 1 de 6 campos preenchidos o
|
|
48
|
+
* painel ocupa altura mostrando quase só travessões, e empurra a conversa — que é o que se veio ver.
|
|
49
|
+
*/
|
|
50
|
+
defaultOpen?: boolean
|
|
51
|
+
labels?: Partial<ConversationContextPanelLabels>
|
|
52
|
+
className?: string
|
|
53
|
+
classNames?: Partial<ConversationContextPanelClassNames>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ConversationContextPanel({
|
|
57
|
+
entries,
|
|
58
|
+
defaultOpen,
|
|
59
|
+
labels: labelsOverride,
|
|
60
|
+
className,
|
|
61
|
+
classNames,
|
|
62
|
+
}: ConversationContextPanelProps) {
|
|
63
|
+
const labels = { ...DEFAULT_CONVERSATION_CONTEXT_LABELS, ...labelsOverride }
|
|
64
|
+
const filled = entries.filter((entry) => Boolean(entry.value))
|
|
65
|
+
|
|
66
|
+
// Abre sozinho só quando há algo coletado: um painel de seis traços ocupa a altura da conversa
|
|
67
|
+
// sem informar nada. O contador continua visível fechado, que é o dado útil de relance.
|
|
68
|
+
//
|
|
69
|
+
// `undefined` = usuário ainda não mexeu. Guardar só um booleano não serve: o contexto chega
|
|
70
|
+
// depois da montagem, então o estado inicial seria calculado com a lista vazia e o painel ficaria
|
|
71
|
+
// fechado mesmo com dados.
|
|
72
|
+
const isNarrow = useIsNarrow()
|
|
73
|
+
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
|
|
74
|
+
// No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa. O
|
|
75
|
+
// contador no cabeçalho já entrega a informação de relance.
|
|
76
|
+
const open = manualOpen ?? defaultOpen ?? (!isNarrow && filled.length > 0)
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<section className={cn('border-b', classNames?.root, className)}>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={() => setManualOpen(!open)}
|
|
83
|
+
aria-expanded={open}
|
|
84
|
+
title={open ? labels.collapse : labels.expand}
|
|
85
|
+
className={cn(
|
|
86
|
+
'flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-gray-50 dark:hover:bg-gray-800',
|
|
87
|
+
classNames?.toggle,
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<span aria-hidden className="text-xs">
|
|
91
|
+
{open ? '▾' : '▸'}
|
|
92
|
+
</span>
|
|
93
|
+
<span>{labels.title}</span>
|
|
94
|
+
<span className={cn('rounded-full bg-gray-200 px-2 text-xs dark:bg-gray-700', classNames?.counter)}>
|
|
95
|
+
{filled.length}/{entries.length}
|
|
96
|
+
</span>
|
|
97
|
+
{/* Rótulo escrito na ponta direita: o caret sozinho não dizia que a linha inteira fecha o
|
|
98
|
+
painel — a pergunta "cadê o botão de fechar?" veio daí. */}
|
|
99
|
+
<span aria-hidden className="ml-auto text-xs text-gray-500">
|
|
100
|
+
{open ? labels.collapse : labels.expand}
|
|
101
|
+
</span>
|
|
102
|
+
</button>
|
|
103
|
+
|
|
104
|
+
{open ? (
|
|
105
|
+
<div className={cn('px-4 pb-3', classNames?.body)}>
|
|
106
|
+
{entries.length === 0 ? (
|
|
107
|
+
<p className="text-xs text-gray-500">{labels.empty}</p>
|
|
108
|
+
) : (
|
|
109
|
+
<dl className="grid grid-cols-2 gap-2 text-xs">
|
|
110
|
+
{entries.map((entry) => (
|
|
111
|
+
<div key={entry.key} className="min-w-0">
|
|
112
|
+
<dt className="truncate text-gray-500">
|
|
113
|
+
{entry.icon ? `${entry.icon} ` : ''}
|
|
114
|
+
{entry.label}
|
|
115
|
+
</dt>
|
|
116
|
+
<dd className="truncate font-medium">
|
|
117
|
+
{entry.value ? <span className="text-green-700 dark:text-green-400">✓ {entry.value}</span> : '—'}
|
|
118
|
+
</dd>
|
|
119
|
+
</div>
|
|
120
|
+
))}
|
|
121
|
+
</dl>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
) : null}
|
|
125
|
+
</section>
|
|
126
|
+
)
|
|
127
|
+
}
|