@dev.smartpricing/message-composer-layer 4.3.2-chat.0 → 4.4.1-next.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/README.md +184 -48
- package/app/api/client.ts +15 -1
- package/app/api/compilation.ts +13 -3
- package/app/components/Common/HtmlPreviewIframe.vue +17 -3
- package/app/components/Common/MessageGroupSelect.vue +44 -18
- package/app/components/Common/MessagePreviewSlideover.vue +3 -0
- package/app/components/Editor/RichTextEditor.vue +8 -4
- package/app/components/MessageSendFlow/EmailPreview.vue +78 -0
- package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +252 -0
- package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
- package/app/components/Whatsapp/Editor/Header.vue +1 -4
- package/app/components/Whatsapp/Preview.vue +16 -6
- package/app/components/Whatsapp/PreviewPanel.vue +7 -1
- package/app/components/Whatsapp/Translator/Header.vue +7 -6
- package/app/composables/useMessageSendFlow.ts +246 -0
- package/app/queries/compilation.ts +1 -1
- package/app/queries/messageGroups.ts +13 -2
- package/app/types/tracking.ts +13 -0
- package/app/utils/testIds.const.ts +23 -0
- package/i18n/locales/de.ts +38 -0
- package/i18n/locales/en.ts +35 -0
- package/i18n/locales/es.ts +36 -0
- package/i18n/locales/fr.ts +35 -0
- package/i18n/locales/it.ts +37 -0
- package/nuxt.config.ts +3 -4
- package/package.json +4 -3
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
PostEntity,
|
|
4
|
+
RenderType,
|
|
5
|
+
WhatsappMessage,
|
|
6
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
7
|
+
import type { StepperStep } from 'nuxt-ui-layer/types'
|
|
8
|
+
import { computed, ref, watchEffect } from 'vue'
|
|
9
|
+
import MessageGroupSelect from '~/components/Common/MessageGroupSelect.vue'
|
|
10
|
+
import WhatsappPreview from '~/components/Whatsapp/Preview.vue'
|
|
11
|
+
import EmailPreview from './EmailPreview.vue'
|
|
12
|
+
import LanguageSelect from './LanguageSelect.vue'
|
|
13
|
+
import ParametersForm from './ParametersForm.vue'
|
|
14
|
+
import { useMessageSendFlow, type MessageSendFlowEmits } from '~/composables/useMessageSendFlow'
|
|
15
|
+
import { useTracking } from '~/composables/useTracking'
|
|
16
|
+
import type { MessageSendFlowStep } from '~/types/tracking'
|
|
17
|
+
import { messageSendFlowTestIds } from '~/utils/testIds.const'
|
|
18
|
+
|
|
19
|
+
defineOptions({ name: 'MessageSendFlow' })
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Message Send Flow (spec #67): Message → Parameters (with the preview beside
|
|
23
|
+
* both), then one `/compile` on Confirm whose Sendable Message is emitted as
|
|
24
|
+
* `compiled`.
|
|
25
|
+
* The host owns the container and the send; the component keeps its state
|
|
26
|
+
* after `compiled` and never resets itself.
|
|
27
|
+
*
|
|
28
|
+
* Anatomy follows the design system's multi-step form: a vertical `SStepper`
|
|
29
|
+
* in a body sidebar, the step content beside it, the channel preview in a
|
|
30
|
+
* right panel on every step, Cancel/Back left and Next/Confirm right in the
|
|
31
|
+
* container's footer (`actionsTo`).
|
|
32
|
+
*/
|
|
33
|
+
const props = withDefaults(
|
|
34
|
+
defineProps<{
|
|
35
|
+
renderType?: RenderType
|
|
36
|
+
/** Preferred language: preselected when approved, else the first approved. */
|
|
37
|
+
language?: string
|
|
38
|
+
/** Editable prefill; keys the Message does not need are ignored. */
|
|
39
|
+
parameters?: Record<string, string>
|
|
40
|
+
/** Brand override passed to `/compile`. */
|
|
41
|
+
brandId?: string
|
|
42
|
+
/**
|
|
43
|
+
* Where to render the action bar (Cancel, Back, Next, Confirm): a selector
|
|
44
|
+
* or element in the host's container, e.g. a `UModal` footer. Unset, the
|
|
45
|
+
* bar renders under the steps.
|
|
46
|
+
*/
|
|
47
|
+
actionsTo?: string | HTMLElement
|
|
48
|
+
}>(),
|
|
49
|
+
{ renderType: 'whatsapp_v1' },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const groupId = defineModel<string>('messageGroupId')
|
|
53
|
+
const emit = defineEmits<MessageSendFlowEmits>()
|
|
54
|
+
|
|
55
|
+
const { t } = useI18n()
|
|
56
|
+
const { trackEvent } = useTracking()
|
|
57
|
+
|
|
58
|
+
const {
|
|
59
|
+
group,
|
|
60
|
+
groupLoading,
|
|
61
|
+
languages,
|
|
62
|
+
sendable,
|
|
63
|
+
selectedLanguage,
|
|
64
|
+
selectedMessage,
|
|
65
|
+
parameterKeys,
|
|
66
|
+
parametersLoading,
|
|
67
|
+
placeholders,
|
|
68
|
+
parameterValues,
|
|
69
|
+
flowError,
|
|
70
|
+
compiling,
|
|
71
|
+
canConfirm,
|
|
72
|
+
confirm,
|
|
73
|
+
} = useMessageSendFlow({
|
|
74
|
+
groupId,
|
|
75
|
+
renderType: () => props.renderType,
|
|
76
|
+
preferredLanguage: () => props.language,
|
|
77
|
+
prefill: () => props.parameters,
|
|
78
|
+
brandId: () => props.brandId,
|
|
79
|
+
emit,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// --- Steps: `useStepperNavigation` keeps each step's status in sync ---------
|
|
83
|
+
const STEP_KEYS: MessageSendFlowStep[] = ['message', 'parameters']
|
|
84
|
+
const steps = ref<StepperStep[]>(
|
|
85
|
+
STEP_KEYS.map((id, index) => ({ id, label: '', status: index === 0 ? 'current' : 'todo' })),
|
|
86
|
+
)
|
|
87
|
+
// Labels follow the locale; statuses are the composable's, so they are patched in place.
|
|
88
|
+
watchEffect(() => {
|
|
89
|
+
for (const step of steps.value) {
|
|
90
|
+
step.label = t(`send_flow.steps.${step.id}.title`)
|
|
91
|
+
step.description = t(`send_flow.steps.${step.id}.description`)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
const activeStepId = ref<string>('message')
|
|
95
|
+
const { currentIndex, canGoBack, goBack, goNext, goToStep } = useStepperNavigation(
|
|
96
|
+
steps,
|
|
97
|
+
activeStepId,
|
|
98
|
+
)
|
|
99
|
+
const currentStep = computed(() => activeStepId.value as MessageSendFlowStep)
|
|
100
|
+
const isLastStep = computed(() => currentIndex.value === steps.value.length - 1)
|
|
101
|
+
|
|
102
|
+
const canNext = computed(() => {
|
|
103
|
+
if (currentStep.value === 'message')
|
|
104
|
+
return !!groupId.value && sendable.value && !!selectedLanguage.value && !groupLoading.value
|
|
105
|
+
// Parameters is the last step: Confirm, not Next (empty fields are its MISSING_PARAMETERS).
|
|
106
|
+
return false
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const approvedCount = computed(() => languages.value.filter((l) => l.approved).length)
|
|
110
|
+
|
|
111
|
+
// The Preview step is the one channel-specific block.
|
|
112
|
+
const whatsappMessage = computed(() =>
|
|
113
|
+
props.renderType === 'whatsapp_v1'
|
|
114
|
+
? (selectedMessage.value as PostEntity<WhatsappMessage> | undefined)
|
|
115
|
+
: undefined,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
function cancel() {
|
|
119
|
+
trackEvent('send_flow.cancel', { step: currentStep.value })
|
|
120
|
+
emit('cancel')
|
|
121
|
+
}
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<template>
|
|
125
|
+
<div class="flex flex-col gap-6" :data-testid="messageSendFlowTestIds.root">
|
|
126
|
+
<UAlert
|
|
127
|
+
v-if="flowError"
|
|
128
|
+
color="error"
|
|
129
|
+
variant="subtle"
|
|
130
|
+
icon="i-lucide-triangle-alert"
|
|
131
|
+
:title="$t('common.error')"
|
|
132
|
+
:description="flowError.message"
|
|
133
|
+
:data-testid="messageSendFlowTestIds.alert"
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
<div class="flex min-h-[420px] gap-6">
|
|
137
|
+
<!-- Done steps are clickable (the stepper's own rule); todo steps only through Next. -->
|
|
138
|
+
<div class="w-[200px] shrink-0 border-r border-default pr-4">
|
|
139
|
+
<SStepper
|
|
140
|
+
v-model="activeStepId"
|
|
141
|
+
:steps="steps"
|
|
142
|
+
:data-testid="messageSendFlowTestIds.stepper"
|
|
143
|
+
@step-click="goToStep"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div class="min-w-0 flex-1">
|
|
148
|
+
<div v-if="currentStep === 'message'" class="max-w-md space-y-4">
|
|
149
|
+
<div class="space-y-2">
|
|
150
|
+
<p class="label-lg">{{ $t('send_flow.questions.message') }}</p>
|
|
151
|
+
<div :data-testid="messageSendFlowTestIds.picker">
|
|
152
|
+
<MessageGroupSelect
|
|
153
|
+
v-model="groupId"
|
|
154
|
+
:render-type="renderType"
|
|
155
|
+
eligibility="sendable"
|
|
156
|
+
class="w-full"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<p class="text-xs text-muted">{{ $t('send_flow.not_sendable_hint') }}</p>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div v-if="group && sendable" class="space-y-2">
|
|
163
|
+
<p class="label-lg">{{ $t('send_flow.questions.language') }}</p>
|
|
164
|
+
<p class="text-sm text-muted">
|
|
165
|
+
{{
|
|
166
|
+
$t('send_flow.language.approved_count', { name: group.name, count: approvedCount })
|
|
167
|
+
}}
|
|
168
|
+
</p>
|
|
169
|
+
<LanguageSelect v-model="selectedLanguage" :languages="languages" />
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div v-else class="max-w-md space-y-3">
|
|
174
|
+
<p class="label-lg">{{ $t('send_flow.questions.parameters') }}</p>
|
|
175
|
+
<ParametersForm
|
|
176
|
+
v-model="parameterValues"
|
|
177
|
+
:parameter-keys="parameterKeys"
|
|
178
|
+
:placeholders="placeholders"
|
|
179
|
+
:loading="parametersLoading"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<!-- The preview follows every step: the picked Message, then the typed values. -->
|
|
185
|
+
<aside
|
|
186
|
+
class="flex w-[400px] shrink-0 flex-col items-center gap-2 border-l border-default pl-6"
|
|
187
|
+
:data-testid="messageSendFlowTestIds.previewPanel"
|
|
188
|
+
>
|
|
189
|
+
<p class="label-lg self-start">{{ $t('send_flow.questions.preview') }}</p>
|
|
190
|
+
<WhatsappPreview
|
|
191
|
+
v-if="whatsappMessage && groupId"
|
|
192
|
+
:message="whatsappMessage"
|
|
193
|
+
:message-group-id="groupId"
|
|
194
|
+
:parameters="parameterValues"
|
|
195
|
+
:data-testid="messageSendFlowTestIds.preview"
|
|
196
|
+
/>
|
|
197
|
+
<EmailPreview
|
|
198
|
+
v-else-if="selectedMessage && groupId && selectedLanguage"
|
|
199
|
+
:message-group-id="groupId"
|
|
200
|
+
:language="selectedLanguage"
|
|
201
|
+
:parameters="parameterValues"
|
|
202
|
+
:brand-id="brandId"
|
|
203
|
+
/>
|
|
204
|
+
<p v-else class="self-start text-sm text-muted">{{ $t('email_preview.no_preview') }}</p>
|
|
205
|
+
</aside>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<!-- `defer`: a host footer renders after the body, so the target is resolved after this render. -->
|
|
209
|
+
<Teleport :to="actionsTo" :disabled="!actionsTo" defer>
|
|
210
|
+
<div
|
|
211
|
+
class="flex w-full items-center justify-between"
|
|
212
|
+
:class="{ 'border-t border-default pt-4': !actionsTo }"
|
|
213
|
+
:data-testid="messageSendFlowTestIds.actions"
|
|
214
|
+
>
|
|
215
|
+
<div class="flex gap-2">
|
|
216
|
+
<UButton
|
|
217
|
+
variant="ghost"
|
|
218
|
+
color="neutral"
|
|
219
|
+
:label="$t('send_flow.actions.cancel')"
|
|
220
|
+
:data-testid="messageSendFlowTestIds.cancel"
|
|
221
|
+
@click="cancel"
|
|
222
|
+
/>
|
|
223
|
+
<UButton
|
|
224
|
+
v-if="canGoBack"
|
|
225
|
+
variant="outline"
|
|
226
|
+
color="neutral"
|
|
227
|
+
:label="$t('send_flow.actions.back')"
|
|
228
|
+
:data-testid="messageSendFlowTestIds.back"
|
|
229
|
+
@click="goBack()"
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
<UButton
|
|
233
|
+
v-if="!isLastStep"
|
|
234
|
+
:label="$t('send_flow.actions.next')"
|
|
235
|
+
trailing-icon="i-lucide-arrow-right"
|
|
236
|
+
:disabled="!canNext"
|
|
237
|
+
:data-testid="messageSendFlowTestIds.next"
|
|
238
|
+
@click="goNext()"
|
|
239
|
+
/>
|
|
240
|
+
<UButton
|
|
241
|
+
v-else
|
|
242
|
+
:label="$t('send_flow.actions.confirm')"
|
|
243
|
+
icon="i-lucide-send"
|
|
244
|
+
:loading="compiling"
|
|
245
|
+
:disabled="!canConfirm"
|
|
246
|
+
:data-testid="messageSendFlowTestIds.confirm"
|
|
247
|
+
@click="confirm()"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</Teleport>
|
|
251
|
+
</div>
|
|
252
|
+
</template>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { messageSendFlowTestIds } from '~/utils/testIds.const'
|
|
3
|
+
|
|
4
|
+
defineOptions({ name: 'MessageSendFlowParametersForm' })
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One required input per operator parameter of the chosen Message. Labels are
|
|
8
|
+
* humanized from the key (`guest_name` → "Guest name"), placeholders are the
|
|
9
|
+
* organization-level values.
|
|
10
|
+
*/
|
|
11
|
+
defineProps<{
|
|
12
|
+
parameterKeys: string[]
|
|
13
|
+
placeholders: Record<string, string>
|
|
14
|
+
loading?: boolean
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const values = defineModel<Record<string, string>>({ required: true })
|
|
18
|
+
|
|
19
|
+
function humanize(key: string): string {
|
|
20
|
+
const words = key.replace(/[_-]+/g, ' ').trim()
|
|
21
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setValue(key: string, value: string) {
|
|
25
|
+
values.value = { ...values.value, [key]: value }
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<div class="space-y-3" :data-testid="messageSendFlowTestIds.parametersForm">
|
|
31
|
+
<div v-if="loading" class="space-y-2">
|
|
32
|
+
<USkeleton class="h-9" />
|
|
33
|
+
<USkeleton class="h-9" />
|
|
34
|
+
</div>
|
|
35
|
+
<template v-else-if="parameterKeys.length">
|
|
36
|
+
<UFormField v-for="key in parameterKeys" :key="key" :label="humanize(key)" required>
|
|
37
|
+
<UInput
|
|
38
|
+
:model-value="values[key] ?? ''"
|
|
39
|
+
:placeholder="placeholders[key]"
|
|
40
|
+
class="w-full"
|
|
41
|
+
:data-testid="messageSendFlowTestIds.parameterInput"
|
|
42
|
+
:data-parameter="key"
|
|
43
|
+
@update:model-value="setValue(key, String($event))"
|
|
44
|
+
/>
|
|
45
|
+
</UFormField>
|
|
46
|
+
</template>
|
|
47
|
+
<p v-else class="text-sm text-muted" :data-testid="messageSendFlowTestIds.noParameters">
|
|
48
|
+
{{ $t('send_flow.no_parameters') }}
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
@@ -112,10 +112,7 @@ function isMultimedia(model: HeaderBlockV1): model is WaHeaderTypeV1Url {
|
|
|
112
112
|
<div v-if="model.type === 'text'" class="mt-3">
|
|
113
113
|
<RichTextEditor
|
|
114
114
|
v-model:text="model.content"
|
|
115
|
-
preset="whatsapp"
|
|
116
|
-
bold
|
|
117
|
-
italic
|
|
118
|
-
emoji
|
|
115
|
+
preset="whatsapp-header"
|
|
119
116
|
improve
|
|
120
117
|
:character-limit="WA_HEADER_TEXT_MAX_LENGTH"
|
|
121
118
|
dynamic-values
|
|
@@ -9,6 +9,7 @@ import { computed } from 'vue'
|
|
|
9
9
|
import {
|
|
10
10
|
generateWhatsappHtml,
|
|
11
11
|
getTipTapExtensions,
|
|
12
|
+
sanitizeContent,
|
|
12
13
|
} from '@dev.smartpricing/message-composer-utils/utils'
|
|
13
14
|
import { useResolvedParametersQuery } from '~/queries/compilation'
|
|
14
15
|
|
|
@@ -19,6 +20,13 @@ defineOptions({
|
|
|
19
20
|
const props = defineProps<{
|
|
20
21
|
message: PostEntity<WhatsappMessage>
|
|
21
22
|
messageGroupId: string
|
|
23
|
+
/**
|
|
24
|
+
* Parameter values that override the resolved (organization-level) ones,
|
|
25
|
+
* e.g. what an operator typed in a Message Send Flow. Non-empty values win,
|
|
26
|
+
* keys not in the map keep the resolved value, an empty string shows the raw
|
|
27
|
+
* `{{key}}` placeholder (as an empty resolved value always did).
|
|
28
|
+
*/
|
|
29
|
+
parameters?: Record<string, string>
|
|
22
30
|
}>()
|
|
23
31
|
|
|
24
32
|
const { data: resolvedParams } = useResolvedParametersQuery(
|
|
@@ -27,13 +35,14 @@ const { data: resolvedParams } = useResolvedParametersQuery(
|
|
|
27
35
|
)
|
|
28
36
|
|
|
29
37
|
function replaceMentionsWithValues(html: string): string {
|
|
30
|
-
const dynamicData = resolvedParams.value?.parameters
|
|
31
|
-
if (!dynamicData) return html
|
|
38
|
+
const dynamicData = { ...resolvedParams.value?.parameters, ...props.parameters }
|
|
32
39
|
|
|
33
40
|
let replacedHtml = html
|
|
34
41
|
for (const [key, value] of Object.entries(dynamicData)) {
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
// Literal match and a replacer function: keys and operator-typed values
|
|
43
|
+
// must never be read as regex or `$` replacement patterns.
|
|
44
|
+
const placeholder = `{{${key}}}`
|
|
45
|
+
replacedHtml = replacedHtml.replaceAll(placeholder, () => value || placeholder)
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
return replacedHtml
|
|
@@ -51,9 +60,10 @@ const bodyHtml = computed(() => {
|
|
|
51
60
|
const headerHtml = computed(() => {
|
|
52
61
|
if (props.message.body.header.type !== 'text') return ''
|
|
53
62
|
if (!props.message.body.header.content) return ''
|
|
63
|
+
const extensions = getTipTapExtensions({ preset: 'whatsapp-header' })
|
|
54
64
|
const html = generateHTML(
|
|
55
|
-
props.message.body.header.content,
|
|
56
|
-
|
|
65
|
+
sanitizeContent(props.message.body.header.content, extensions),
|
|
66
|
+
extensions,
|
|
57
67
|
).replaceAll('<p></p>', '<p> </p>')
|
|
58
68
|
return replaceMentionsWithValues(html)
|
|
59
69
|
})
|
|
@@ -9,6 +9,8 @@ defineOptions({
|
|
|
9
9
|
defineProps<{
|
|
10
10
|
message: PostEntity<WhatsappMessage>
|
|
11
11
|
messageGroupId: string
|
|
12
|
+
/** Forwarded to `WhatsappPreview`: operator-typed values overriding the resolved ones. */
|
|
13
|
+
parameters?: Record<string, string>
|
|
12
14
|
}>()
|
|
13
15
|
</script>
|
|
14
16
|
|
|
@@ -19,6 +21,10 @@ defineProps<{
|
|
|
19
21
|
<h3 class="label-lg">{{ $t('common.preview') }}</h3>
|
|
20
22
|
</div>
|
|
21
23
|
|
|
22
|
-
<WhatsappPreview
|
|
24
|
+
<WhatsappPreview
|
|
25
|
+
:message="message"
|
|
26
|
+
:message-group-id="messageGroupId"
|
|
27
|
+
:parameters="parameters"
|
|
28
|
+
/>
|
|
23
29
|
</div>
|
|
24
30
|
</template>
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { JSONContent } from '@tiptap/vue-3'
|
|
3
3
|
import { generateHTML } from '@tiptap/vue-3'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getTipTapExtensions,
|
|
6
|
+
sanitizeContent,
|
|
7
|
+
} from '@dev.smartpricing/message-composer-utils/utils'
|
|
5
8
|
import RichTextEditor from '@/components/Editor/RichTextEditor.vue'
|
|
6
9
|
import { useAppContextStore } from '@/stores/appContext'
|
|
7
10
|
import { WA_HEADER_TEXT_MAX_LENGTH } from '@dev.smartpricing/message-composer-utils/types'
|
|
@@ -23,7 +26,8 @@ const appContext = useAppContextStore()
|
|
|
23
26
|
// Generate HTML for preview of base language with rich text support
|
|
24
27
|
function generatePreviewHtml(content: JSONContent | undefined): string {
|
|
25
28
|
if (!content) return ''
|
|
26
|
-
|
|
29
|
+
const extensions = getTipTapExtensions({ preset: 'whatsapp-header' })
|
|
30
|
+
return generateHTML(sanitizeContent(content, extensions), extensions).replaceAll(
|
|
27
31
|
'<p></p>',
|
|
28
32
|
'<p> </p>',
|
|
29
33
|
)
|
|
@@ -45,10 +49,7 @@ function generatePreviewHtml(content: JSONContent | undefined): string {
|
|
|
45
49
|
<div class="flex-1">
|
|
46
50
|
<RichTextEditor
|
|
47
51
|
v-model:text="targetContent"
|
|
48
|
-
preset="whatsapp"
|
|
49
|
-
bold
|
|
50
|
-
italic
|
|
51
|
-
emoji
|
|
52
|
+
preset="whatsapp-header"
|
|
52
53
|
dynamic-values
|
|
53
54
|
:character-limit="WA_HEADER_TEXT_MAX_LENGTH"
|
|
54
55
|
:data-testid="whatsappTranslatorHeaderTestIds.targetEditor"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useQuery } from '@pinia/colada'
|
|
2
|
+
import type { MaybeRefOrGetter, Ref } from 'vue'
|
|
3
|
+
import { computed, ref, toValue, watch } from 'vue'
|
|
4
|
+
import type {
|
|
5
|
+
EmailMessage,
|
|
6
|
+
MessageStatus,
|
|
7
|
+
PostEntity,
|
|
8
|
+
RenderType,
|
|
9
|
+
SendableMessage,
|
|
10
|
+
WhatsappMessage,
|
|
11
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
12
|
+
import { compilationApi } from '~/api'
|
|
13
|
+
import { useResolvedParametersQuery } from '~/queries/compilation'
|
|
14
|
+
import { useMessageGroupQuery } from '~/queries/messageGroups'
|
|
15
|
+
import { useTracking } from '~/composables/useTracking'
|
|
16
|
+
|
|
17
|
+
/** Codes a host receives on `error`; the backend ones pass through unchanged. */
|
|
18
|
+
export type MessageSendFlowErrorCode =
|
|
19
|
+
| 'NOT_SENDABLE'
|
|
20
|
+
| 'MISSING_PARAMETERS'
|
|
21
|
+
| 'TEMPLATE_NOT_SUBMITTED'
|
|
22
|
+
| 'TEMPLATE_NOT_APPROVED'
|
|
23
|
+
| 'COMPILE_FAILED'
|
|
24
|
+
|
|
25
|
+
const BACKEND_CODES = new Set<MessageSendFlowErrorCode>([
|
|
26
|
+
'TEMPLATE_NOT_SUBMITTED',
|
|
27
|
+
'TEMPLATE_NOT_APPROVED',
|
|
28
|
+
'MISSING_PARAMETERS',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
export interface MessageSendFlowError {
|
|
32
|
+
code: MessageSendFlowErrorCode
|
|
33
|
+
/** The layer's translated text, the same string the inline alert shows. */
|
|
34
|
+
message: string
|
|
35
|
+
/** Missing keys (client-side) or the backend error body. */
|
|
36
|
+
details?: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MessageSendFlowEmits {
|
|
40
|
+
compiled: [message: SendableMessage]
|
|
41
|
+
cancel: []
|
|
42
|
+
error: [error: MessageSendFlowError]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MessageSendFlowLanguage {
|
|
46
|
+
code: string
|
|
47
|
+
status: MessageStatus
|
|
48
|
+
approved: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What an operator must fill; `brand_*` keys are baked server-side. */
|
|
52
|
+
export function isOperatorParameter(key: string): boolean {
|
|
53
|
+
return !key.startsWith('brand_')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Data flow of the Message Send Flow (spec #67): Sendable group → approved
|
|
58
|
+
* language → parameters → one `/compile` on Confirm. The component owns the
|
|
59
|
+
* steps and the buttons; the host owns the container and the send.
|
|
60
|
+
*/
|
|
61
|
+
export function useMessageSendFlow(options: {
|
|
62
|
+
groupId: Ref<string | undefined>
|
|
63
|
+
renderType: MaybeRefOrGetter<RenderType>
|
|
64
|
+
preferredLanguage: MaybeRefOrGetter<string | undefined>
|
|
65
|
+
prefill: MaybeRefOrGetter<Record<string, string> | undefined>
|
|
66
|
+
brandId: MaybeRefOrGetter<string | undefined>
|
|
67
|
+
emit: <K extends keyof MessageSendFlowEmits>(event: K, ...args: MessageSendFlowEmits[K]) => void
|
|
68
|
+
}) {
|
|
69
|
+
const { groupId, emit } = options
|
|
70
|
+
const { t } = useI18n()
|
|
71
|
+
const { trackEvent } = useTracking()
|
|
72
|
+
|
|
73
|
+
// --- Group -------------------------------------------------------------
|
|
74
|
+
const { data: group, isLoading: groupLoading } = useMessageGroupQuery(() => groupId.value ?? '', {
|
|
75
|
+
enabled: () => !!groupId.value,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const messages = computed(() =>
|
|
79
|
+
(group.value?.messages ?? []).filter((m) => m.render_type === toValue(options.renderType)),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const languages = computed<MessageSendFlowLanguage[]>(() =>
|
|
83
|
+
messages.value.map((m) => ({
|
|
84
|
+
code: m.language_id,
|
|
85
|
+
status: m.status,
|
|
86
|
+
approved: m.status === 'approved',
|
|
87
|
+
})),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const sendable = computed(() => languages.value.some((l) => l.approved))
|
|
91
|
+
const notSendable = computed(
|
|
92
|
+
() => !!groupId.value && !!group.value && !groupLoading.value && !sendable.value,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// --- Language rule: preferred if approved, else first approved -----------
|
|
96
|
+
const selectedLanguage = ref<string>()
|
|
97
|
+
watch(
|
|
98
|
+
[messages, () => toValue(options.preferredLanguage)],
|
|
99
|
+
([msgs, preferred]) => {
|
|
100
|
+
const approved = msgs.filter((m) => m.status === 'approved')
|
|
101
|
+
const match = approved.find((m) => m.language_id === preferred) ?? approved[0]
|
|
102
|
+
selectedLanguage.value = match?.language_id
|
|
103
|
+
},
|
|
104
|
+
{ immediate: true },
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const selectedMessage = computed(
|
|
108
|
+
() =>
|
|
109
|
+
messages.value.find((m) => m.language_id === selectedLanguage.value) as
|
|
110
|
+
| PostEntity<WhatsappMessage>
|
|
111
|
+
| PostEntity<EmailMessage>
|
|
112
|
+
| undefined,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// --- Parameters ----------------------------------------------------------
|
|
116
|
+
const parametersEnabled = () => !!groupId.value && !!selectedLanguage.value
|
|
117
|
+
|
|
118
|
+
const { data: parametersData, isLoading: parametersLoading } = useQuery({
|
|
119
|
+
key: () => ['send-flow', 'parameters', groupId.value ?? '', selectedLanguage.value ?? ''],
|
|
120
|
+
query: () => compilationApi.getParameters(groupId.value ?? '', selectedLanguage.value ?? ''),
|
|
121
|
+
enabled: parametersEnabled,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Same query as WhatsappPreview, so the Preview step reuses the cached values.
|
|
125
|
+
const { data: resolvedData } = useResolvedParametersQuery(
|
|
126
|
+
() => groupId.value ?? '',
|
|
127
|
+
() => selectedLanguage.value ?? '',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const parameterKeys = computed(() =>
|
|
131
|
+
(parametersData.value?.parameters ?? []).filter(isOperatorParameter),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/** Organization-level values, shown as input placeholders. */
|
|
135
|
+
const placeholders = computed<Record<string, string>>(() => resolvedData.value?.parameters ?? {})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One entry per visible key; prefill seeds new keys, unknown prefill keys
|
|
139
|
+
* are ignored. Rebuilt only once the keys are known, so a language switch
|
|
140
|
+
* keeps what the operator typed while the next list loads.
|
|
141
|
+
*/
|
|
142
|
+
const parameterValues = ref<Record<string, string>>({})
|
|
143
|
+
watch(
|
|
144
|
+
parameterKeys,
|
|
145
|
+
(keys) => {
|
|
146
|
+
if (!parametersData.value) return
|
|
147
|
+
const prefill = toValue(options.prefill) ?? {}
|
|
148
|
+
const next: Record<string, string> = {}
|
|
149
|
+
for (const key of keys) next[key] = parameterValues.value[key] ?? prefill[key] ?? ''
|
|
150
|
+
parameterValues.value = next
|
|
151
|
+
},
|
|
152
|
+
{ immediate: true },
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const missing = computed(() =>
|
|
156
|
+
parameterKeys.value.filter((k) => !parameterValues.value[k]?.trim()),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
// --- Errors ---------------------------------------------------------------
|
|
160
|
+
const flowError = ref<MessageSendFlowError>()
|
|
161
|
+
|
|
162
|
+
function fail(code: MessageSendFlowErrorCode, details?: unknown) {
|
|
163
|
+
const error: MessageSendFlowError = {
|
|
164
|
+
code,
|
|
165
|
+
message: t(`send_flow.errors.${code}`, { name: group.value?.name ?? '' }),
|
|
166
|
+
details,
|
|
167
|
+
}
|
|
168
|
+
flowError.value = error
|
|
169
|
+
trackEvent('send_flow.error', { code })
|
|
170
|
+
emit('error', error)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
watch(notSendable, (value) => {
|
|
174
|
+
if (value) {
|
|
175
|
+
fail('NOT_SENDABLE')
|
|
176
|
+
} else if (flowError.value?.code === 'NOT_SENDABLE') {
|
|
177
|
+
flowError.value = undefined
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// --- Compile (once, on Confirm) --------------------------------------------
|
|
182
|
+
const compiling = ref(false)
|
|
183
|
+
const canConfirm = computed(() => !!selectedMessage.value && !compiling.value)
|
|
184
|
+
|
|
185
|
+
async function confirm() {
|
|
186
|
+
flowError.value = undefined
|
|
187
|
+
if (!groupId.value || !selectedLanguage.value || !group.value) return
|
|
188
|
+
|
|
189
|
+
if (missing.value.length > 0) {
|
|
190
|
+
fail('MISSING_PARAMETERS', { missing: missing.value })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
trackEvent('send_flow.confirm', {
|
|
195
|
+
message_name: group.value.name,
|
|
196
|
+
message_category: group.value.category,
|
|
197
|
+
language: selectedLanguage.value,
|
|
198
|
+
parameters_count: parameterKeys.value.length,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
compiling.value = true
|
|
202
|
+
try {
|
|
203
|
+
const res = await compilationApi.compile(
|
|
204
|
+
groupId.value,
|
|
205
|
+
{
|
|
206
|
+
language: selectedLanguage.value,
|
|
207
|
+
parameters: { ...parameterValues.value },
|
|
208
|
+
strict: true,
|
|
209
|
+
strict_language: true,
|
|
210
|
+
brand_id: toValue(options.brandId),
|
|
211
|
+
},
|
|
212
|
+
{ skipErrorToast: true },
|
|
213
|
+
)
|
|
214
|
+
if (!('message' in res)) {
|
|
215
|
+
fail('COMPILE_FAILED', res)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
emit('compiled', res.message)
|
|
219
|
+
} catch (e) {
|
|
220
|
+
const { status, data } = e as { status?: number; data?: { code?: string } }
|
|
221
|
+
// 401 reaches the host through `composer:auth-error`; nothing to add here.
|
|
222
|
+
if (status === 401) return
|
|
223
|
+
const code = data?.code as MessageSendFlowErrorCode | undefined
|
|
224
|
+
fail(code && BACKEND_CODES.has(code) ? code : 'COMPILE_FAILED', data ?? e)
|
|
225
|
+
} finally {
|
|
226
|
+
compiling.value = false
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
group,
|
|
232
|
+
groupLoading,
|
|
233
|
+
languages,
|
|
234
|
+
sendable,
|
|
235
|
+
selectedLanguage,
|
|
236
|
+
selectedMessage,
|
|
237
|
+
parameterKeys,
|
|
238
|
+
parametersLoading,
|
|
239
|
+
placeholders,
|
|
240
|
+
parameterValues,
|
|
241
|
+
flowError,
|
|
242
|
+
compiling,
|
|
243
|
+
canConfirm,
|
|
244
|
+
confirm,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -37,7 +37,7 @@ export function useResolvedParametersQuery(
|
|
|
37
37
|
export function useCompileEmailMutation() {
|
|
38
38
|
return useMutation({
|
|
39
39
|
mutation: ({ messageGroupId, data }: { messageGroupId: string; data: CompileMessageRequest }) =>
|
|
40
|
-
compilationApi.
|
|
40
|
+
compilationApi.compile(messageGroupId, data),
|
|
41
41
|
})
|
|
42
42
|
}
|
|
43
43
|
|