@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.
@@ -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
- const mentionRegex = new RegExp(`{{${key}}}`, 'g')
36
- replacedHtml = replacedHtml.replace(mentionRegex, value || `{{${key}}}`)
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
- getTipTapExtensions({ preset: 'whatsapp-header' }),
65
+ sanitizeContent(props.message.body.header.content, extensions),
66
+ extensions,
57
67
  ).replaceAll('<p></p>', '<p>&nbsp;</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 :message="message" :message-group-id="messageGroupId" />
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 { getTipTapExtensions } from '@dev.smartpricing/message-composer-utils/utils'
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
- return generateHTML(content, getTipTapExtensions({ preset: 'whatsapp-header' })).replaceAll(
29
+ const extensions = getTipTapExtensions({ preset: 'whatsapp-header' })
30
+ return generateHTML(sanitizeContent(content, extensions), extensions).replaceAll(
27
31
  '<p></p>',
28
32
  '<p>&nbsp;</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.compileEmail(messageGroupId, data),
40
+ compilationApi.compile(messageGroupId, data),
41
41
  })
42
42
  }
43
43