@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.21
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-TV4OQRGH.js +2187 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +862 -136
- package/dist/index.js +1746 -1124
- package/dist/preview/index.d.ts +470 -0
- package/dist/preview/index.js +1329 -0
- package/dist/styles.css +228 -0
- package/dist/types-C6A_9edv.d.ts +456 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +280 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +44 -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 +96 -22
- package/src/MessageBubble.tsx +77 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +165 -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 +69 -7
- 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 +122 -0
- package/src/conversationTranscript.ts +89 -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/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +140 -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 +334 -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 +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +194 -0
- package/src/preview/createPreviewWebhookClient.ts +222 -0
- package/src/preview/index.ts +67 -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/previewMediaUploader.test.ts +61 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +175 -12
- package/src/quickReply.test.ts +58 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +173 -0
- package/src/types.ts +72 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { FormEvent } from 'react'
|
|
2
|
+
import { AudioLines, Save } from 'lucide-react'
|
|
3
|
+
import type { TranscriptionMode } from '../types'
|
|
4
|
+
|
|
5
|
+
export interface TranscriptionSettingsFormLabels {
|
|
6
|
+
sectionTitle: string
|
|
7
|
+
enabled: string
|
|
8
|
+
enabledHint: string
|
|
9
|
+
modeTitle: string
|
|
10
|
+
modeAuto: string
|
|
11
|
+
modeAutoHint: string
|
|
12
|
+
modeOnDemand: string
|
|
13
|
+
modeOnDemandHint: string
|
|
14
|
+
unavailable: string
|
|
15
|
+
save: string
|
|
16
|
+
saving: string
|
|
17
|
+
saveSuccess: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LABELS: TranscriptionSettingsFormLabels = {
|
|
21
|
+
sectionTitle: 'Transcrição de áudio',
|
|
22
|
+
enabled: 'Transcrever notas de voz',
|
|
23
|
+
enabledHint: 'Converte o áudio do cliente em texto, que o atendente lê e copia direto da conversa.',
|
|
24
|
+
modeTitle: 'Quando transcrever',
|
|
25
|
+
modeAuto: 'Automaticamente',
|
|
26
|
+
modeAutoHint: 'Toda nota de voz recebida é transcrita na hora. Mais cômodo, consome mais cota.',
|
|
27
|
+
modeOnDemand: 'Quando o atendente pedir',
|
|
28
|
+
modeOnDemandHint: 'Transcreve só ao clicar no botão da conversa. Consome cota apenas com áudio que alguém vai ler.',
|
|
29
|
+
unavailable: 'Transcrição não está disponível neste ambiente — fale com quem administra a instalação.',
|
|
30
|
+
save: 'Salvar',
|
|
31
|
+
saving: 'Salvando...',
|
|
32
|
+
saveSuccess: 'Configuração de transcrição salva.',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TranscriptionSettingsFormProps {
|
|
36
|
+
enabled: boolean
|
|
37
|
+
onEnabledChange: (value: boolean) => void
|
|
38
|
+
mode: TranscriptionMode
|
|
39
|
+
onModeChange: (value: TranscriptionMode) => void
|
|
40
|
+
onSave: (event: FormEvent) => void
|
|
41
|
+
/**
|
|
42
|
+
* A capacidade existe no servidor (engine e credencial configurados)?
|
|
43
|
+
*
|
|
44
|
+
* Distinto de `enabled`: isto é "o ambiente consegue", aquilo é "esta empresa quer". Sem a
|
|
45
|
+
* distinção, um lojista ligaria o interruptor num ambiente sem engine e concluiria que o produto
|
|
46
|
+
* está quebrado — o texto nunca apareceria e nada explicaria por quê.
|
|
47
|
+
*/
|
|
48
|
+
isAvailable?: boolean
|
|
49
|
+
saving?: boolean
|
|
50
|
+
saveSuccess?: boolean
|
|
51
|
+
labels?: Partial<TranscriptionSettingsFormLabels>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Interruptor de transcrição por empresa, para a tela de configurações.
|
|
56
|
+
*
|
|
57
|
+
* Puramente apresentacional, como os outros forms daqui: quem persiste é o host. O pacote não sabe a
|
|
58
|
+
* rota, e a política vive nas configurações do módulo (`settings.transcriptionEnabled`), não em
|
|
59
|
+
* variável de ambiente — mudar de ideia sobre transcrever não deveria exigir deploy.
|
|
60
|
+
*/
|
|
61
|
+
export function TranscriptionSettingsForm({
|
|
62
|
+
enabled,
|
|
63
|
+
onEnabledChange,
|
|
64
|
+
mode,
|
|
65
|
+
onModeChange,
|
|
66
|
+
onSave,
|
|
67
|
+
isAvailable = true,
|
|
68
|
+
saving = false,
|
|
69
|
+
saveSuccess = false,
|
|
70
|
+
labels: labelsOverride,
|
|
71
|
+
}: TranscriptionSettingsFormProps) {
|
|
72
|
+
const labels = { ...DEFAULT_LABELS, ...labelsOverride }
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
76
|
+
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
|
77
|
+
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-1.5">
|
|
78
|
+
<AudioLines size={14} className="text-blue-600 dark:text-blue-400" />
|
|
79
|
+
{labels.sectionTitle}
|
|
80
|
+
</h2>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<form onSubmit={onSave} className="p-6 space-y-5">
|
|
84
|
+
{!isAvailable && (
|
|
85
|
+
<div className="bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-xl px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
|
|
86
|
+
{labels.unavailable}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
<label className="flex items-start gap-3 cursor-pointer">
|
|
91
|
+
<input
|
|
92
|
+
type="checkbox"
|
|
93
|
+
checked={enabled}
|
|
94
|
+
onChange={(event) => onEnabledChange(event.target.checked)}
|
|
95
|
+
disabled={!isAvailable}
|
|
96
|
+
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
|
|
97
|
+
/>
|
|
98
|
+
<span>
|
|
99
|
+
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{labels.enabled}</span>
|
|
100
|
+
<span className="block text-xs text-gray-500 dark:text-gray-400">{labels.enabledHint}</span>
|
|
101
|
+
</span>
|
|
102
|
+
</label>
|
|
103
|
+
|
|
104
|
+
{/* O modo só aparece quando ligado: escolher "quando transcrever" sem transcrever nada é uma
|
|
105
|
+
pergunta sem consequência, e ler as duas opções gasta atenção do operador à toa. */}
|
|
106
|
+
{enabled && (
|
|
107
|
+
<fieldset disabled={!isAvailable} className="space-y-2.5">
|
|
108
|
+
<legend className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{labels.modeTitle}</legend>
|
|
109
|
+
|
|
110
|
+
<ModeOption
|
|
111
|
+
value="onDemand"
|
|
112
|
+
current={mode}
|
|
113
|
+
onSelect={onModeChange}
|
|
114
|
+
title={labels.modeOnDemand}
|
|
115
|
+
hint={labels.modeOnDemandHint}
|
|
116
|
+
/>
|
|
117
|
+
<ModeOption
|
|
118
|
+
value="auto"
|
|
119
|
+
current={mode}
|
|
120
|
+
onSelect={onModeChange}
|
|
121
|
+
title={labels.modeAuto}
|
|
122
|
+
hint={labels.modeAutoHint}
|
|
123
|
+
/>
|
|
124
|
+
</fieldset>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{saveSuccess && (
|
|
128
|
+
<div className="bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-800 rounded-xl px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
|
129
|
+
{labels.saveSuccess}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
<div className="flex justify-end">
|
|
134
|
+
<button
|
|
135
|
+
type="submit"
|
|
136
|
+
disabled={saving || !isAvailable}
|
|
137
|
+
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
|
|
138
|
+
>
|
|
139
|
+
{saving ? (
|
|
140
|
+
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
|
141
|
+
) : (
|
|
142
|
+
<Save size={14} />
|
|
143
|
+
)}
|
|
144
|
+
{saving ? labels.saving : labels.save}
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
</form>
|
|
148
|
+
</section>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function ModeOption({
|
|
153
|
+
value,
|
|
154
|
+
current,
|
|
155
|
+
onSelect,
|
|
156
|
+
title,
|
|
157
|
+
hint,
|
|
158
|
+
}: {
|
|
159
|
+
value: TranscriptionMode
|
|
160
|
+
current: TranscriptionMode
|
|
161
|
+
onSelect: (value: TranscriptionMode) => void
|
|
162
|
+
title: string
|
|
163
|
+
hint: string
|
|
164
|
+
}) {
|
|
165
|
+
const isSelected = current === value
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<label
|
|
169
|
+
className={`flex items-start gap-3 cursor-pointer rounded-xl border px-4 py-3 transition-colors ${
|
|
170
|
+
isSelected
|
|
171
|
+
? 'border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/30'
|
|
172
|
+
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
|
173
|
+
}`}
|
|
174
|
+
>
|
|
175
|
+
<input
|
|
176
|
+
type="radio"
|
|
177
|
+
name="transcription-mode"
|
|
178
|
+
value={value}
|
|
179
|
+
checked={isSelected}
|
|
180
|
+
onChange={() => onSelect(value)}
|
|
181
|
+
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
182
|
+
/>
|
|
183
|
+
<span>
|
|
184
|
+
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{title}</span>
|
|
185
|
+
<span className="block text-xs text-gray-500 dark:text-gray-400">{hint}</span>
|
|
186
|
+
</span>
|
|
187
|
+
</label>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -24,6 +24,7 @@ export interface WhatsAppCreateTemplateFormLabels {
|
|
|
24
24
|
sectionDescription: string
|
|
25
25
|
nameLabel: string
|
|
26
26
|
nameHint: string
|
|
27
|
+
namePlaceholder: string
|
|
27
28
|
categoryLabel: string
|
|
28
29
|
languageLabel: string
|
|
29
30
|
headerLabel: string
|
|
@@ -57,6 +58,7 @@ const DEFAULT_LABELS: WhatsAppCreateTemplateFormLabels = {
|
|
|
57
58
|
sectionDescription: 'Envia um template para aprovação da Meta.',
|
|
58
59
|
nameLabel: 'Nome do template',
|
|
59
60
|
nameHint: 'Somente letras minúsculas, números e underscore.',
|
|
61
|
+
namePlaceholder: 'reengajamento_cliente',
|
|
60
62
|
categoryLabel: 'Categoria',
|
|
61
63
|
languageLabel: 'Idioma',
|
|
62
64
|
headerLabel: 'Cabeçalho',
|
|
@@ -142,7 +144,7 @@ export function WhatsAppCreateTemplateForm({
|
|
|
142
144
|
type="text"
|
|
143
145
|
value={value.name}
|
|
144
146
|
onChange={(e) => set('name', e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
|
|
145
|
-
placeholder=
|
|
147
|
+
placeholder={labels.namePlaceholder}
|
|
146
148
|
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all"
|
|
147
149
|
required
|
|
148
150
|
/>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurações de template do WhatsApp, com submenu próprio.
|
|
3
|
+
*
|
|
4
|
+
* Junta as duas telas que já existiam soltas — escolher o template de reengajamento e criar um novo
|
|
5
|
+
* para aprovação da Meta — porque na cabeça de quem opera é um só assunto. Cada host montava esse
|
|
6
|
+
* agrupamento por conta, e é justamente o tipo de composição que deve nascer no pacote.
|
|
7
|
+
*
|
|
8
|
+
* Segue presentacional: nenhuma chamada de rede aqui. Templates, estado e handlers vêm por props.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, type FormEvent } from 'react'
|
|
12
|
+
import {
|
|
13
|
+
WhatsAppTemplateSettingsForm,
|
|
14
|
+
type WhatsAppTemplateSummary,
|
|
15
|
+
type WhatsAppTemplateVariableSuggestion,
|
|
16
|
+
} from './WhatsAppTemplateSettingsForm'
|
|
17
|
+
import {
|
|
18
|
+
WhatsAppCreateTemplateForm,
|
|
19
|
+
type WhatsAppCreateTemplateResult,
|
|
20
|
+
type WhatsAppCreateTemplateState,
|
|
21
|
+
} from './WhatsAppCreateTemplateForm'
|
|
22
|
+
|
|
23
|
+
export const TEMPLATE_SETTINGS_TAB = {
|
|
24
|
+
SELECT: 'select',
|
|
25
|
+
CREATE: 'create',
|
|
26
|
+
} as const
|
|
27
|
+
export type TemplateSettingsTab = (typeof TEMPLATE_SETTINGS_TAB)[keyof typeof TEMPLATE_SETTINGS_TAB]
|
|
28
|
+
|
|
29
|
+
export interface WhatsAppTemplatesSettingsLabels {
|
|
30
|
+
selectTab: string
|
|
31
|
+
createTab: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_TEMPLATES_SETTINGS_LABELS: WhatsAppTemplatesSettingsLabels = {
|
|
35
|
+
selectTab: 'Template de reengajamento',
|
|
36
|
+
createTab: 'Criar template',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WhatsAppTemplatesSettingsProps {
|
|
40
|
+
templates: WhatsAppTemplateSummary[]
|
|
41
|
+
loadingTemplates?: boolean
|
|
42
|
+
templatesError?: boolean
|
|
43
|
+
onRefreshTemplates?: () => void
|
|
44
|
+
selectedTemplateName: string
|
|
45
|
+
onSelectTemplate: (name: string, template: WhatsAppTemplateSummary | undefined) => void
|
|
46
|
+
variables: string[]
|
|
47
|
+
onVariablesChange: (variables: string[]) => void
|
|
48
|
+
availableVariables?: WhatsAppTemplateVariableSuggestion[]
|
|
49
|
+
saving?: boolean
|
|
50
|
+
saveSuccess?: boolean
|
|
51
|
+
onSave: (event: FormEvent) => void
|
|
52
|
+
/** Ausente = host não sabe criar template; a aba de criação nem aparece. */
|
|
53
|
+
create?: {
|
|
54
|
+
value: WhatsAppCreateTemplateState
|
|
55
|
+
onChange: (value: WhatsAppCreateTemplateState) => void
|
|
56
|
+
onSubmit: (event: FormEvent) => void
|
|
57
|
+
submitting?: boolean
|
|
58
|
+
result?: WhatsAppCreateTemplateResult | null
|
|
59
|
+
}
|
|
60
|
+
labels?: Partial<WhatsAppTemplatesSettingsLabels>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function WhatsAppTemplatesSettings({
|
|
64
|
+
labels: labelsOverride,
|
|
65
|
+
create,
|
|
66
|
+
...settingsProps
|
|
67
|
+
}: WhatsAppTemplatesSettingsProps) {
|
|
68
|
+
const labels = { ...DEFAULT_TEMPLATES_SETTINGS_LABELS, ...labelsOverride }
|
|
69
|
+
const [tab, setTab] = useState<TemplateSettingsTab>(TEMPLATE_SETTINGS_TAB.SELECT)
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="space-y-4">
|
|
73
|
+
<nav className="flex gap-1 border-b" role="tablist">
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
role="tab"
|
|
77
|
+
aria-selected={tab === TEMPLATE_SETTINGS_TAB.SELECT}
|
|
78
|
+
onClick={() => setTab(TEMPLATE_SETTINGS_TAB.SELECT)}
|
|
79
|
+
className={`cv-subtab ${tab === TEMPLATE_SETTINGS_TAB.SELECT ? 'cv-subtab--active' : ''}`}
|
|
80
|
+
>
|
|
81
|
+
{labels.selectTab}
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
{/* A aba de criação só existe se o host souber criar: sem handler, ela seria um formulário
|
|
85
|
+
que não leva a nada. */}
|
|
86
|
+
{create ? (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
role="tab"
|
|
90
|
+
aria-selected={tab === TEMPLATE_SETTINGS_TAB.CREATE}
|
|
91
|
+
onClick={() => setTab(TEMPLATE_SETTINGS_TAB.CREATE)}
|
|
92
|
+
className={`cv-subtab ${tab === TEMPLATE_SETTINGS_TAB.CREATE ? 'cv-subtab--active' : ''}`}
|
|
93
|
+
>
|
|
94
|
+
{labels.createTab}
|
|
95
|
+
</button>
|
|
96
|
+
) : null}
|
|
97
|
+
</nav>
|
|
98
|
+
|
|
99
|
+
{tab === TEMPLATE_SETTINGS_TAB.SELECT ? (
|
|
100
|
+
<WhatsAppTemplateSettingsForm {...settingsProps} />
|
|
101
|
+
) : create ? (
|
|
102
|
+
<WhatsAppCreateTemplateForm {...create} />
|
|
103
|
+
) : null}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -19,3 +19,176 @@
|
|
|
19
19
|
background-color: #0b141a;
|
|
20
20
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%2319232a' stroke-width='1.2' opacity='0.9'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
/* Botão de ação do cabeçalho de conversa e do aviso de janela. Vive no stylesheet, como o
|
|
24
|
+
.cv-wallpaper, para não depender da configuração de Tailwind do consumidor. */
|
|
25
|
+
.cv-header-action {
|
|
26
|
+
border: 1px solid rgb(203 213 225);
|
|
27
|
+
border-radius: 0.375rem;
|
|
28
|
+
padding: 0.375rem 0.75rem;
|
|
29
|
+
font-size: 0.8125rem;
|
|
30
|
+
background: transparent;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
.cv-header-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
34
|
+
.dark .cv-header-action { border-color: rgb(71 85 105); color: rgb(226 232 240); }
|
|
35
|
+
|
|
36
|
+
.cv-header-icon {
|
|
37
|
+
border: 1px solid rgb(203 213 225);
|
|
38
|
+
border-radius: 0.5rem;
|
|
39
|
+
padding: 0.375rem 0.5rem;
|
|
40
|
+
background: transparent;
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
line-height: 1;
|
|
43
|
+
}
|
|
44
|
+
.cv-header-icon--active { background: rgb(241 245 249); border-color: rgb(148 163 184); }
|
|
45
|
+
.cv-header-icon:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
46
|
+
.dark .cv-header-icon { border-color: rgb(71 85 105); }
|
|
47
|
+
.dark .cv-header-icon--active { background: rgb(51 65 85); }
|
|
48
|
+
|
|
49
|
+
.cv-header-action--primary {
|
|
50
|
+
background: rgb(37 99 235);
|
|
51
|
+
border-color: rgb(37 99 235);
|
|
52
|
+
color: white;
|
|
53
|
+
font-weight: 500;
|
|
54
|
+
}
|
|
55
|
+
.cv-header-action--danger {
|
|
56
|
+
background: rgb(239 68 68);
|
|
57
|
+
border-color: rgb(239 68 68);
|
|
58
|
+
color: white;
|
|
59
|
+
font-weight: 500;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.cv-subtab {
|
|
63
|
+
padding: 0.5rem 0.75rem;
|
|
64
|
+
font-size: 0.8125rem;
|
|
65
|
+
color: rgb(100 116 139);
|
|
66
|
+
background: transparent;
|
|
67
|
+
border: 0;
|
|
68
|
+
border-bottom: 2px solid transparent;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
}
|
|
71
|
+
.cv-subtab--active { color: inherit; font-weight: 500; border-bottom-color: rgb(37 99 235); }
|
|
72
|
+
|
|
73
|
+
/* Pills de estado da conversa. No stylesheet, como os demais cv-*, para não depender do Tailwind
|
|
74
|
+
do consumidor. */
|
|
75
|
+
.cv-pill {
|
|
76
|
+
display: inline-flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
gap: 0.25rem;
|
|
79
|
+
font-size: 0.6875rem;
|
|
80
|
+
font-weight: 500;
|
|
81
|
+
line-height: 1;
|
|
82
|
+
padding: 0.25rem 0.5rem;
|
|
83
|
+
border-radius: 9999px;
|
|
84
|
+
background: rgb(241 245 249);
|
|
85
|
+
color: rgb(71 85 105);
|
|
86
|
+
}
|
|
87
|
+
.cv-pill--success { background: rgb(220 252 231); color: rgb(21 128 61); }
|
|
88
|
+
.cv-pill--warning { background: rgb(254 243 199); color: rgb(146 64 14); }
|
|
89
|
+
.cv-pill--danger { background: rgb(255 237 213); color: rgb(194 65 12); }
|
|
90
|
+
.dark .cv-pill { background: rgb(51 65 85); color: rgb(203 213 225); }
|
|
91
|
+
.dark .cv-pill--success { background: rgb(20 83 45); color: rgb(187 247 208); }
|
|
92
|
+
.dark .cv-pill--warning { background: rgb(120 53 15); color: rgb(253 230 138); }
|
|
93
|
+
.dark .cv-pill--danger { background: rgb(124 45 18); color: rgb(254 215 170); }
|
|
94
|
+
|
|
95
|
+
/* Linha da inbox: realce e hover cobrem a linha toda, incluindo checkbox, barra de janela e pills. */
|
|
96
|
+
.cv-row:hover { background: #f0f2f5; }
|
|
97
|
+
.cv-row--active, .cv-row--active:hover { background: #f0f2f5; }
|
|
98
|
+
.dark .cv-row:hover { background: rgb(30 41 59); }
|
|
99
|
+
.dark .cv-row--active, .dark .cv-row--active:hover { background: rgb(30 41 59); }
|
|
100
|
+
|
|
101
|
+
/* Voltar do master/detail: existe só onde lista e conversa não cabem lado a lado. */
|
|
102
|
+
.cv-back {
|
|
103
|
+
border: 1px solid rgb(203 213 225);
|
|
104
|
+
border-radius: 0.5rem;
|
|
105
|
+
padding: 0.25rem 0.5rem;
|
|
106
|
+
background: transparent;
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
line-height: 1;
|
|
109
|
+
}
|
|
110
|
+
@media (min-width: 1024px) { .cv-back { display: none; } }
|
|
111
|
+
.dark .cv-back { border-color: rgb(71 85 105); color: rgb(226 232 240); }
|
|
112
|
+
|
|
113
|
+
/* Utilitários de densidade: em tela estreita o texto das ações vira ícone, porque rótulo longo
|
|
114
|
+
empilha e come a altura útil da conversa. */
|
|
115
|
+
.cv-only-wide { display: none; }
|
|
116
|
+
@media (min-width: 1024px) { .cv-only-wide { display: inline; } }
|
|
117
|
+
.cv-only-narrow { display: inline-flex; }
|
|
118
|
+
@media (min-width: 1024px) { .cv-only-narrow { display: none; } }
|
|
119
|
+
|
|
120
|
+
.cv-menu { position: relative; }
|
|
121
|
+
.cv-menu-panel {
|
|
122
|
+
position: absolute;
|
|
123
|
+
right: 0;
|
|
124
|
+
top: calc(100% + 0.25rem);
|
|
125
|
+
z-index: 20;
|
|
126
|
+
min-width: 12rem;
|
|
127
|
+
display: flex;
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
gap: 0.25rem;
|
|
130
|
+
padding: 0.375rem;
|
|
131
|
+
border: 1px solid rgb(203 213 225);
|
|
132
|
+
border-radius: 0.5rem;
|
|
133
|
+
background: #fff;
|
|
134
|
+
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
|
135
|
+
}
|
|
136
|
+
.dark .cv-menu-panel { background: rgb(30 41 59); border-color: rgb(71 85 105); }
|
|
137
|
+
.cv-menu-panel .cv-header-action { text-align: left; }
|
|
138
|
+
|
|
139
|
+
/* Área de toque mínima do padrão de responsividade (44px). Só em tela estreita: no desktop o
|
|
140
|
+
cursor é preciso e botões de 44px viram blocos desproporcionais. */
|
|
141
|
+
@media (max-width: 1023px) {
|
|
142
|
+
.cv-touch { min-width: 44px; min-height: 44px; }
|
|
143
|
+
.cv-back { min-width: 44px; min-height: 44px; }
|
|
144
|
+
.cv-menu-panel .cv-touch { min-height: 44px; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* Em tela estreita o painel do menu ancora na viewport, não no botão: ancorado ao botão ele
|
|
148
|
+
escapava pela borda quando o gatilho ficava perto da esquerda, e o texto era cortado. */
|
|
149
|
+
@media (max-width: 1023px) {
|
|
150
|
+
.cv-menu-panel {
|
|
151
|
+
position: fixed;
|
|
152
|
+
right: 0.75rem;
|
|
153
|
+
left: auto;
|
|
154
|
+
top: auto;
|
|
155
|
+
max-width: calc(100vw - 1.5rem);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Scrollbar fina — paridade com `.scrollbar-thin` de
|
|
161
|
+
* financiamento-imobiliario-bot/apps/web/src/index.css.
|
|
162
|
+
*
|
|
163
|
+
* Prefixo `cv-` porque o host pode ter a própria `.scrollbar-thin` (plugin do Tailwind ou CSS
|
|
164
|
+
* próprio): colidir no nome faria o painel herdar regra alheia sem ninguém entender por quê.
|
|
165
|
+
*
|
|
166
|
+
* Usada onde a área rola dentro de um bloco de altura limitada, como o grid de "Suas Seleções":
|
|
167
|
+
* a barra padrão do sistema ocupa ~15px e, num card de 3 colunas, come uma coluna inteira.
|
|
168
|
+
*/
|
|
169
|
+
.cv-scrollbar-thin {
|
|
170
|
+
scrollbar-width: thin;
|
|
171
|
+
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
|
|
172
|
+
}
|
|
173
|
+
.cv-scrollbar-thin::-webkit-scrollbar { width: 4px; height: 4px; }
|
|
174
|
+
.cv-scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
|
175
|
+
.cv-scrollbar-thin::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); border-radius: 2px; }
|
|
176
|
+
.cv-scrollbar-thin::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.3); }
|
|
177
|
+
.dark .cv-scrollbar-thin { scrollbar-color: rgba(255, 255, 255, 0.15) transparent; }
|
|
178
|
+
.dark .cv-scrollbar-thin::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); }
|
|
179
|
+
.dark .cv-scrollbar-thin::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); }
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Mãozinha no que é clicável.
|
|
183
|
+
*
|
|
184
|
+
* O Preflight do Tailwind v3 punha `cursor: pointer` em `button`; o v4 removeu, e sem regra
|
|
185
|
+
* explícita o botão herda o `cursor: default` do browser — os componentes deste pacote deixam de
|
|
186
|
+
* parecer clicáveis num host v4. São 91 botões aqui: carimbar a classe em cada um seria ruído que o
|
|
187
|
+
* próximo componente esqueceria de repetir.
|
|
188
|
+
*
|
|
189
|
+
* `:where()` zera a especificidade de propósito: a regra vence o UA stylesheet (autor sempre vence)
|
|
190
|
+
* mas perde de qualquer regra do host, então quem quiser outro cursor não precisa de `!important`.
|
|
191
|
+
*/
|
|
192
|
+
:where(button:not(:disabled), [role='button']:not(:disabled), summary) {
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -20,9 +20,46 @@ export interface ConversationsFeatures {
|
|
|
20
20
|
darkMode?: boolean
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Recorte do bloco `interactive` da Meta que a UI precisa para desenhar o menu. Fica solto (e não
|
|
25
|
+
* espelhando o contrato inteiro) porque o que chega do banco é o payload cru já enviado ao
|
|
26
|
+
* WhatsApp: qualquer campo que a UI não conheça é ignorado, nunca causa erro de render.
|
|
27
|
+
*/
|
|
28
|
+
export interface InteractiveOption {
|
|
29
|
+
id: string
|
|
30
|
+
title: string
|
|
31
|
+
description?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface InteractiveSection {
|
|
35
|
+
title?: string
|
|
36
|
+
rows?: InteractiveOption[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InteractivePayload {
|
|
40
|
+
type?: 'button' | 'list' | string
|
|
41
|
+
header?: { text?: string }
|
|
42
|
+
body?: { text?: string }
|
|
43
|
+
footer?: { text?: string }
|
|
44
|
+
action?: {
|
|
45
|
+
/** Rótulo do botão que abre a lista — só existe em `type: 'list'`. */
|
|
46
|
+
button?: string
|
|
47
|
+
sections?: InteractiveSection[]
|
|
48
|
+
buttons?: { reply?: InteractiveOption }[]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Como o cliente respondeu a um menu: por botão ou por item de lista. */
|
|
53
|
+
export type InteractiveSelection = {
|
|
54
|
+
readonly kind: 'button' | 'list'
|
|
55
|
+
readonly option: InteractiveOption
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
export interface MessagePayload {
|
|
24
59
|
id: string
|
|
25
|
-
type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template'
|
|
60
|
+
type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template' | 'interactive'
|
|
61
|
+
/** Payload cru da mensagem. Em `type: 'interactive'`, carrega o menu que o cliente vê. */
|
|
62
|
+
payload?: InteractivePayload | null
|
|
26
63
|
content?: string
|
|
27
64
|
caption?: string
|
|
28
65
|
mediaUrl?: string
|
|
@@ -39,6 +76,40 @@ export interface MessagePayload {
|
|
|
39
76
|
readAt?: string
|
|
40
77
|
agentName?: string | null
|
|
41
78
|
templateName?: string
|
|
79
|
+
/**
|
|
80
|
+
* Veredito de moderação vindo do backend — a UI só exibe, nunca calcula. Dicionário no browser
|
|
81
|
+
* seria peso morto e daria veredito diferente por versão de cliente.
|
|
82
|
+
*
|
|
83
|
+
* `null`/ausente = não avaliado (moderação desligada, ou mensagem anterior ao recurso), que é
|
|
84
|
+
* diferente de avaliado e limpo.
|
|
85
|
+
*/
|
|
86
|
+
moderation?: { isOffensive: boolean; terms: string[] } | null
|
|
87
|
+
/**
|
|
88
|
+
* Transcrição do áudio, vinda do backend — a UI só exibe, nunca transcreve. Rodar STT no browser
|
|
89
|
+
* exigiria baixar modelo por aba e daria resultado diferente por versão de cliente.
|
|
90
|
+
*
|
|
91
|
+
* `null`/ausente = não avaliado, que é diferente de `'done'` com texto vazio (áudio em silêncio,
|
|
92
|
+
* já processado). É essa distinção que decide se o balão oferece "transcrever" ou "sem fala
|
|
93
|
+
* detectada".
|
|
94
|
+
*/
|
|
95
|
+
transcription?: MessageTranscription | null
|
|
42
96
|
isFirstInGroup?: boolean
|
|
43
97
|
isLastInGroup?: boolean
|
|
44
98
|
}
|
|
99
|
+
|
|
100
|
+
export type TranscriptionStatus = 'pending' | 'done' | 'failed' | 'unsupported'
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Quando transcrever, escolhido nas configurações da empresa. Espelha o
|
|
104
|
+
* `TranscriptionMode` de `@adatechnology/meta-whatsapp-contracts`; declarado aqui para o pacote de
|
|
105
|
+
* UI não obrigar quem só desenha telas a instalar os contratos do backend.
|
|
106
|
+
*/
|
|
107
|
+
export type TranscriptionMode = 'auto' | 'onDemand'
|
|
108
|
+
|
|
109
|
+
export interface MessageTranscription {
|
|
110
|
+
status: TranscriptionStatus
|
|
111
|
+
text?: string | null
|
|
112
|
+
/** ISO 639-1 ou nome do idioma, conforme o engine. Exibido como dica, não interpretado. */
|
|
113
|
+
language?: string | null
|
|
114
|
+
engine?: string | null
|
|
115
|
+
}
|
package/src/useDarkMode.ts
CHANGED
|
@@ -13,6 +13,32 @@ function getInitialDark(): boolean {
|
|
|
13
13
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Leitura passiva do tema do host: observa a classe `dark` no `<html>` e não escreve nada.
|
|
18
|
+
*
|
|
19
|
+
* Existe porque `useDarkMode` é um controlador — ele grava a classe e persiste a preferência. Um
|
|
20
|
+
* componente que só precisa escolher cor não pode usá-lo: bastava renderizar o mapa de fluxos
|
|
21
|
+
* para o app inteiro do host trocar de tema, seguindo o `prefers-color-scheme` do sistema em vez
|
|
22
|
+
* da configuração da aplicação.
|
|
23
|
+
*/
|
|
24
|
+
export function useIsDarkTheme(): boolean {
|
|
25
|
+
const [isDark, setIsDark] = useState(
|
|
26
|
+
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const root = document.documentElement
|
|
31
|
+
const observer = new MutationObserver(() => setIsDark(root.classList.contains('dark')))
|
|
32
|
+
|
|
33
|
+
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
|
|
34
|
+
setIsDark(root.classList.contains('dark'))
|
|
35
|
+
|
|
36
|
+
return () => observer.disconnect()
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
return isDark
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
export function useDarkMode(): { isDark: boolean; toggle: () => void } {
|
|
17
43
|
const [isDark, setIsDark] = useState(getInitialDark)
|
|
18
44
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecta tela estreita para decisões que CSS não resolve — como abrir ou não um painel por padrão.
|
|
3
|
+
*
|
|
4
|
+
* O breakpoint é o mesmo das classes `cv-only-*` e `.cv-back` (1024px). Duplicado aqui porque
|
|
5
|
+
* JavaScript não lê media query do stylesheet; se um dia divergirem, o sintoma é painel abrindo
|
|
6
|
+
* numa largura onde o resto da UI já mudou de modo.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
|
|
11
|
+
export const NARROW_MAX_WIDTH_PX = 1023
|
|
12
|
+
|
|
13
|
+
export function useIsNarrow(): boolean {
|
|
14
|
+
const [isNarrow, setIsNarrow] = useState(
|
|
15
|
+
() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${NARROW_MAX_WIDTH_PX}px)`).matches,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const query = window.matchMedia(`(max-width: ${NARROW_MAX_WIDTH_PX}px)`)
|
|
20
|
+
const handleChange = (event: MediaQueryListEvent): void => setIsNarrow(event.matches)
|
|
21
|
+
|
|
22
|
+
query.addEventListener('change', handleChange)
|
|
23
|
+
setIsNarrow(query.matches)
|
|
24
|
+
|
|
25
|
+
return () => query.removeEventListener('change', handleChange)
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
return isNarrow
|
|
29
|
+
}
|