@dev.smartpricing/message-composer-layer 4.3.2-fix.1 → 4.5.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.
Files changed (61) hide show
  1. package/README.md +228 -46
  2. package/app/api/brands.ts +20 -13
  3. package/app/api/client.ts +15 -6
  4. package/app/api/compilation.ts +13 -3
  5. package/app/api/dynamicValues.ts +6 -2
  6. package/app/api/media.ts +6 -3
  7. package/app/api/messageGroups.ts +29 -14
  8. package/app/api/meta.ts +26 -4
  9. package/app/api/organization.ts +10 -1
  10. package/app/api/tracking.ts +2 -2
  11. package/app/components/Common/AppContextErrors.vue +30 -0
  12. package/app/components/Common/HtmlPreviewIframe.vue +17 -3
  13. package/app/components/Common/MessageGroupSelect.vue +164 -26
  14. package/app/components/Common/MessagePreviewSlideover.vue +3 -0
  15. package/app/components/Common/OrganizationGate.vue +51 -0
  16. package/app/components/Common/OrganizationSelect.vue +27 -0
  17. package/app/components/Common/WhatsappNotConfigured.vue +20 -0
  18. package/app/components/Composer/EmailComposer.vue +4 -0
  19. package/app/components/Composer/MessageLibrary.vue +17 -7
  20. package/app/components/Composer/WabaSelectModal.vue +75 -0
  21. package/app/components/Composer/WhatsappComposer.vue +78 -3
  22. package/app/components/MessageSendFlow/EmailPreview.vue +81 -0
  23. package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
  24. package/app/components/MessageSendFlow/MessageSendFlow.vue +265 -0
  25. package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
  26. package/app/components/TemplateList/BaseTable.vue +156 -108
  27. package/app/components/Whatsapp/Preview.vue +12 -4
  28. package/app/components/Whatsapp/PreviewPanel.vue +7 -1
  29. package/app/composables/composerContext.ts +9 -7
  30. package/app/composables/useEffectiveOrganization.ts +34 -0
  31. package/app/composables/useMessageGroupList.ts +186 -0
  32. package/app/composables/useMessageGroupWaba.ts +73 -0
  33. package/app/composables/useMessageSendFlow.ts +284 -0
  34. package/app/composables/usePagedMessageGroups.ts +78 -0
  35. package/app/composables/useTemplateOperations.ts +23 -4
  36. package/app/composables/useTracking.ts +14 -5
  37. package/app/composables/useWhatsappConfiguration.ts +32 -0
  38. package/app/layouts/default.vue +5 -1
  39. package/app/pages/brands.vue +1 -0
  40. package/app/pages/images.vue +4 -0
  41. package/app/pages/index.vue +1 -0
  42. package/app/pages/preview/[id].vue +2 -0
  43. package/app/pages/template/whatsapp/[id].vue +1 -0
  44. package/app/queries/brands.ts +27 -7
  45. package/app/queries/compilation.ts +16 -3
  46. package/app/queries/dynamicValues.ts +19 -2
  47. package/app/queries/media.ts +8 -3
  48. package/app/queries/messageGroups.ts +56 -17
  49. package/app/queries/meta.ts +13 -8
  50. package/app/stores/appContext.ts +18 -48
  51. package/app/stores/effectiveOrganization.ts +128 -0
  52. package/app/stores/emailEditor.ts +5 -1
  53. package/app/stores/metaSubmission.ts +7 -17
  54. package/app/types/tracking.ts +17 -0
  55. package/app/utils/testIds.const.ts +29 -0
  56. package/i18n/locales/de.ts +84 -2
  57. package/i18n/locales/en.ts +80 -2
  58. package/i18n/locales/es.ts +81 -2
  59. package/i18n/locales/fr.ts +81 -2
  60. package/i18n/locales/it.ts +82 -2
  61. package/package.json +2 -2
@@ -27,8 +27,33 @@ const { mainLanguage } = storeToRefs(useAppContextStore())
27
27
  const { trackEvent } = useTracking()
28
28
  const { goBackOrFallback } = useGoBack()
29
29
  const { submitToMeta, submitMessageToMeta } = useMetaSubmissionStore()
30
+ const { status: whatsappConfiguration } = useWhatsappConfiguration()
30
31
  const { confirm } = useConfirm()
31
32
  const { t } = useI18n()
33
+ const toast = useToast()
34
+
35
+ /**
36
+ * A submission needs a WABA of the Message Group's Organization: when it is
37
+ * known to have none, or the Message Group is on one the Organization no
38
+ * longer has, say so before anything is saved on the way to the submission.
39
+ * While that is not known (lookup running or failed) the submission goes
40
+ * ahead and the backend decides.
41
+ */
42
+ function canSubmitToMeta(): boolean {
43
+ const reason =
44
+ whatsappConfiguration.value === 'not_configured'
45
+ ? 'whatsapp_not_configured'
46
+ : isWabaDetached.value
47
+ ? 'whatsapp_waba_detached'
48
+ : null
49
+ if (!reason) return true
50
+ toast.add({
51
+ color: 'error',
52
+ title: t(`common.${reason}_title`),
53
+ description: t(`common.${reason}_description`),
54
+ })
55
+ return false
56
+ }
32
57
 
33
58
  // ── Init ──────────────────────────────────────────────────────────
34
59
  if (props.mode === 'create') {
@@ -41,6 +66,19 @@ const { data: messageGroupData, isPending: isFetching } =
41
66
  ? useMessageGroupQuery(props.messageGroupId)
42
67
  : { data: ref(null), isPending: ref(false) }
43
68
 
69
+ // The editor works on the Organization of the Message Group it loaded, not on
70
+ // the selector's. Claimed before the watcher below loads dependent data.
71
+ useResourceOrganization(() => messageGroupData.value?.organization_id)
72
+
73
+ // The WABA the Message Group is on, and the choice of one at its first
74
+ // submission (ADR 0015).
75
+ const {
76
+ waba: messageGroupWaba,
77
+ isDetached: isWabaDetached,
78
+ chooseWaba,
79
+ markSubmitted,
80
+ } = useMessageGroupWaba(() => messageGroupData.value?.waba_id)
81
+
44
82
  // ── Change tracking ───────────────────────────────────────────────
45
83
  const changeTracking = useChangeTracking(computed(() => store.changeTrackingId))
46
84
 
@@ -238,6 +276,10 @@ async function saveAsNew() {
238
276
  }
239
277
 
240
278
  async function requestApproval() {
279
+ // Asked before anything is saved: giving up leaves the editor as it was.
280
+ const choice = await chooseWaba()
281
+ if (!choice) return
282
+
241
283
  track('request_approval', getWaDraftProps())
242
284
 
243
285
  if (props.mode === 'create') {
@@ -246,7 +288,7 @@ async function requestApproval() {
246
288
  'draft',
247
289
  )
248
290
  if (result) {
249
- await submitToMeta(result.id.toString())
291
+ await submitToMeta(result.id.toString(), choice.wabaId)
250
292
  emit('saved', { id: result.id, name: result.name })
251
293
  notifyParent('TEMPLATE_APPROVAL_REQUESTED', { id: result.id, name: result.name })
252
294
  }
@@ -256,7 +298,7 @@ async function requestApproval() {
256
298
  messageGroup: store.messageGroupUpdateData,
257
299
  messages: store.messages,
258
300
  })
259
- await submitToMeta(store.messageGroupId)
301
+ await submitToMeta(store.messageGroupId, choice.wabaId)
260
302
  emit('saved', { id: store.messageGroupId, name: store.name })
261
303
  notifyParent('TEMPLATE_APPROVAL_REQUESTED', { id: store.messageGroupId, name: store.name })
262
304
  goBackOrFallback()
@@ -267,6 +309,10 @@ async function requestApproval() {
267
309
  async function onRequestReview(languageId: string) {
268
310
  const message = store.messages[languageId] as (WhatsappMessage & { id?: string }) | undefined
269
311
  if (!message?.id) return
312
+ if (!canSubmitToMeta()) return
313
+
314
+ const choice = await chooseWaba()
315
+ if (!choice) return
270
316
 
271
317
  const languageName = languages.value?.find((l) => l.id === languageId)?.name ?? languageId
272
318
 
@@ -278,7 +324,8 @@ async function onRequestReview(languageId: string) {
278
324
  action: async () => {
279
325
  // Decision C: persist only this language's current edits, then submit it.
280
326
  await messagesApi.update(message.id!, message)
281
- await submitMessageToMeta(message.id!)
327
+ await submitMessageToMeta(message.id!, choice.wabaId)
328
+ markSubmitted(choice.wabaId)
282
329
  // Reflect pending locally instead of refetching the group, so unsaved
283
330
  // edits in other languages are preserved.
284
331
  store.messages[languageId] = { ...message, status: 'pending' }
@@ -312,6 +359,7 @@ async function handleSaveAsNew() {
312
359
  }
313
360
 
314
361
  async function handleRequestApproval() {
362
+ if (!canSubmitToMeta()) return
315
363
  if (!(await validateFull())) return
316
364
  if (hasIncompleteTranslations.value) {
317
365
  pendingAction.value = 'request_approval'
@@ -409,6 +457,7 @@ defineExpose({
409
457
  validationErrors,
410
458
  isPending: store.isPending,
411
459
  isApproved: store.isApproved,
460
+ isWabaDetached,
412
461
  })
413
462
  </script>
414
463
 
@@ -439,6 +488,32 @@ defineExpose({
439
488
  </UAlert>
440
489
  </div>
441
490
 
491
+ <UAlert
492
+ v-if="isWabaDetached"
493
+ color="warning"
494
+ variant="subtle"
495
+ icon="i-lucide-unplug"
496
+ :title="$t('common.whatsapp_waba_detached_title')"
497
+ :description="$t('common.whatsapp_waba_detached_description')"
498
+ class="rounded-none"
499
+ data-testid="whatsapp-waba-detached"
500
+ />
501
+ <div
502
+ v-if="messageGroupWaba"
503
+ class="flex items-center gap-2 px-6 py-2 text-sm text-muted border-b border-default"
504
+ data-testid="whatsapp-message-group-waba"
505
+ >
506
+ <UIcon name="i-lucide-building-2" class="size-4 shrink-0" />
507
+ <span>{{ $t('common.whatsapp_waba_label') }}</span>
508
+ <span v-if="messageGroupWaba.name" class="font-medium text-default">
509
+ {{ messageGroupWaba.name }}
510
+ </span>
511
+ <span v-if="messageGroupWaba.phone_numbers.length">
512
+ {{ messageGroupWaba.phone_numbers.join(', ') }}
513
+ </span>
514
+ <span class="text-xs text-dimmed font-mono">{{ messageGroupWaba.id }}</span>
515
+ </div>
516
+
442
517
  <ComposerWhatsappContent
443
518
  v-if="store.activeTab === 'content'"
444
519
  :mode="mode"
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ import { useQuery } from '@pinia/colada'
3
+ import { computed, toRef } from 'vue'
4
+ import { compilationApi } from '~/api'
5
+ import HtmlPreviewIframe from '~/components/Common/HtmlPreviewIframe.vue'
6
+ import { messageSendFlowTestIds } from '~/utils/testIds.const'
7
+
8
+ defineOptions({ name: 'MessageSendFlowEmailPreview' })
9
+
10
+ /**
11
+ * The compiled email of the Preview step: `/compile` with the operator's
12
+ * parameter values (not strict, so an empty field shows as its placeholder;
13
+ * Confirm is the strict call), the brand cascade applied server-side.
14
+ */
15
+ const props = defineProps<{
16
+ messageGroupId: string
17
+ language: string
18
+ parameters: Record<string, string>
19
+ brandId?: string
20
+ }>()
21
+
22
+ // The panel follows the form live; one compile per pause, not per keystroke.
23
+ const debouncedParameters = refDebounced(toRef(props, 'parameters'), 300)
24
+
25
+ const organization = useEffectiveOrganizationStore()
26
+
27
+ const { data, isPending, isLoading, error } = useQuery({
28
+ key: () => [
29
+ 'send-flow',
30
+ 'email-preview',
31
+ props.messageGroupId,
32
+ props.language,
33
+ props.brandId ?? '',
34
+ JSON.stringify(debouncedParameters.value),
35
+ organization.organizationId,
36
+ ],
37
+ query: () =>
38
+ compilationApi.compile(
39
+ props.messageGroupId,
40
+ {
41
+ language: props.language,
42
+ parameters: { ...debouncedParameters.value },
43
+ strict: false,
44
+ strict_language: true,
45
+ brand_id: props.brandId,
46
+ },
47
+ { skipErrorToast: true },
48
+ ),
49
+ // Keep the last email on screen while the next one compiles: no skeleton, no blank.
50
+ placeholderData: (previousData) => previousData,
51
+ })
52
+
53
+ const compiled = computed(() => (data.value?.render_type === 'email_v1' ? data.value : undefined))
54
+ </script>
55
+
56
+ <template>
57
+ <div class="w-full max-w-[600px]" :data-testid="messageSendFlowTestIds.emailPreview">
58
+ <div v-if="isPending && !compiled" class="space-y-2">
59
+ <USkeleton class="h-6 w-1/3" />
60
+ <USkeleton class="h-4" />
61
+ <USkeleton class="h-32" />
62
+ </div>
63
+ <div
64
+ v-else-if="compiled"
65
+ class="transition-opacity duration-200"
66
+ :class="{ 'opacity-60': isLoading }"
67
+ >
68
+ <p class="mb-2">
69
+ <span class="paragraph-sm-bold">{{ $t('email_preview.subject') }}:&nbsp;</span>
70
+ <span class="paragraph-sm">{{ compiled.subject }}</span>
71
+ </p>
72
+ <HtmlPreviewIframe
73
+ :html="compiled.html"
74
+ title="Email preview"
75
+ :min-height="480"
76
+ class="w-full rounded-lg border border-default bg-white"
77
+ />
78
+ </div>
79
+ <p v-else-if="error" class="text-sm text-muted">{{ $t('email_preview.no_preview') }}</p>
80
+ </div>
81
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { MessageSendFlowLanguage } from '~/composables/useMessageSendFlow'
4
+ import { messageSendFlowTestIds } from '~/utils/testIds.const'
5
+
6
+ defineOptions({ name: 'MessageSendFlowLanguageSelect' })
7
+
8
+ /**
9
+ * The approved Messages of a Sendable group, one radio card per language.
10
+ * Non-approved languages stay visible but disabled, with their status.
11
+ */
12
+ const props = defineProps<{
13
+ languages: MessageSendFlowLanguage[]
14
+ }>()
15
+
16
+ const modelValue = defineModel<string>()
17
+
18
+ const { t } = useI18n()
19
+
20
+ const items = computed(() =>
21
+ props.languages.map((l) => ({
22
+ value: l.code,
23
+ label: l.code.toUpperCase(),
24
+ description: l.approved
25
+ ? t('send_flow.language.approved')
26
+ : t('send_flow.language.not_sendable', { status: t(`status.${l.status}`) }),
27
+ disabled: !l.approved,
28
+ })),
29
+ )
30
+ </script>
31
+
32
+ <template>
33
+ <URadioGroup
34
+ v-model="modelValue"
35
+ :items="items"
36
+ variant="card"
37
+ :data-testid="messageSendFlowTestIds.languageGroup"
38
+ />
39
+ </template>
@@ -0,0 +1,265 @@
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 type { MessageSendFlowStep } from '~/types/tracking'
16
+ import { messageSendFlowTestIds } from '~/utils/testIds.const'
17
+
18
+ defineOptions({ name: 'MessageSendFlow' })
19
+
20
+ /**
21
+ * Message Send Flow (spec #67): Message → Parameters (with the preview beside
22
+ * both), then one `/compile` on Confirm whose Sendable Message is emitted as
23
+ * `compiled`.
24
+ * The host owns the container and the send; the component keeps its state
25
+ * after `compiled` and never resets itself.
26
+ *
27
+ * Anatomy follows the design system's multi-step form: a vertical `SStepper`
28
+ * in a body sidebar, the step content beside it, the channel preview in a
29
+ * right panel on every step, Cancel/Back left and Next/Confirm right in the
30
+ * container's footer (`actionsTo`).
31
+ */
32
+ const props = withDefaults(
33
+ defineProps<{
34
+ renderType?: RenderType
35
+ /** Preferred language: preselected when approved, else the first approved. */
36
+ language?: string
37
+ /** Editable prefill; keys the Message does not need are ignored. */
38
+ parameters?: Record<string, string>
39
+ /** Brand override passed to `/compile`. */
40
+ brandId?: string
41
+ /**
42
+ * The Organization the flow works on (ADR 0014), e.g. the conversation's.
43
+ * Unset, the one the layer resolved. The flow never shows a selector.
44
+ */
45
+ organizationId?: string
46
+ /**
47
+ * The WABA the Host sends from: only WhatsApp groups on it are offered
48
+ * (ADR 0015). Unset, the groups of every WABA of the Organization.
49
+ */
50
+ wabaId?: string
51
+ /**
52
+ * Where to render the action bar (Cancel, Back, Next, Confirm): a selector
53
+ * or element in the host's container, e.g. a `UModal` footer. Unset, the
54
+ * bar renders under the steps.
55
+ */
56
+ actionsTo?: string | HTMLElement
57
+ }>(),
58
+ { renderType: 'whatsapp_v1' },
59
+ )
60
+
61
+ const groupId = defineModel<string>('messageGroupId')
62
+ const emit = defineEmits<MessageSendFlowEmits>()
63
+
64
+ const { t } = useI18n()
65
+
66
+ const {
67
+ group,
68
+ groupLoading,
69
+ languages,
70
+ sendable,
71
+ selectedLanguage,
72
+ selectedMessage,
73
+ parameterKeys,
74
+ parametersLoading,
75
+ placeholders,
76
+ parameterValues,
77
+ flowError,
78
+ compiling,
79
+ canConfirm,
80
+ confirm,
81
+ trackEvent,
82
+ } = useMessageSendFlow({
83
+ groupId,
84
+ renderType: () => props.renderType,
85
+ preferredLanguage: () => props.language,
86
+ prefill: () => props.parameters,
87
+ brandId: () => props.brandId,
88
+ organizationId: () => props.organizationId,
89
+ wabaId: () => props.wabaId,
90
+ emit,
91
+ })
92
+
93
+ // --- Steps: `useStepperNavigation` keeps each step's status in sync ---------
94
+ const STEP_KEYS: MessageSendFlowStep[] = ['message', 'parameters']
95
+ const steps = ref<StepperStep[]>(
96
+ STEP_KEYS.map((id, index) => ({ id, label: '', status: index === 0 ? 'current' : 'todo' })),
97
+ )
98
+ // Labels follow the locale; statuses are the composable's, so they are patched in place.
99
+ watchEffect(() => {
100
+ for (const step of steps.value) {
101
+ step.label = t(`send_flow.steps.${step.id}.title`)
102
+ step.description = t(`send_flow.steps.${step.id}.description`)
103
+ }
104
+ })
105
+ const activeStepId = ref<string>('message')
106
+ const { currentIndex, canGoBack, goBack, goNext, goToStep } = useStepperNavigation(
107
+ steps,
108
+ activeStepId,
109
+ )
110
+ const currentStep = computed(() => activeStepId.value as MessageSendFlowStep)
111
+ const isLastStep = computed(() => currentIndex.value === steps.value.length - 1)
112
+
113
+ const canNext = computed(() => {
114
+ if (currentStep.value === 'message')
115
+ return !!groupId.value && sendable.value && !!selectedLanguage.value && !groupLoading.value
116
+ // Parameters is the last step: Confirm, not Next (empty fields are its MISSING_PARAMETERS).
117
+ return false
118
+ })
119
+
120
+ const approvedCount = computed(() => languages.value.filter((l) => l.approved).length)
121
+
122
+ // The Preview step is the one channel-specific block.
123
+ const whatsappMessage = computed(() =>
124
+ props.renderType === 'whatsapp_v1'
125
+ ? (selectedMessage.value as PostEntity<WhatsappMessage> | undefined)
126
+ : undefined,
127
+ )
128
+
129
+ function cancel() {
130
+ trackEvent('send_flow.cancel', { step: currentStep.value })
131
+ emit('cancel')
132
+ }
133
+ </script>
134
+
135
+ <template>
136
+ <div class="flex flex-col gap-6" :data-testid="messageSendFlowTestIds.root">
137
+ <UAlert
138
+ v-if="flowError"
139
+ color="error"
140
+ variant="subtle"
141
+ icon="i-lucide-triangle-alert"
142
+ :title="$t('common.error')"
143
+ :description="flowError.message"
144
+ :data-testid="messageSendFlowTestIds.alert"
145
+ />
146
+
147
+ <div class="flex min-h-[420px] gap-6">
148
+ <!-- Done steps are clickable (the stepper's own rule); todo steps only through Next. -->
149
+ <div class="w-[200px] shrink-0 border-r border-default pr-4">
150
+ <SStepper
151
+ v-model="activeStepId"
152
+ :steps="steps"
153
+ :data-testid="messageSendFlowTestIds.stepper"
154
+ @step-click="goToStep"
155
+ />
156
+ </div>
157
+
158
+ <div class="min-w-0 flex-1">
159
+ <div v-if="currentStep === 'message'" class="max-w-md space-y-4">
160
+ <div class="space-y-2">
161
+ <p class="label-lg">{{ $t('send_flow.questions.message') }}</p>
162
+ <div :data-testid="messageSendFlowTestIds.picker">
163
+ <MessageGroupSelect
164
+ v-model="groupId"
165
+ :render-type="renderType"
166
+ :organization-id="organizationId"
167
+ :waba-id="wabaId"
168
+ eligibility="sendable"
169
+ class="w-full"
170
+ />
171
+ </div>
172
+ <p class="text-xs text-muted">{{ $t('send_flow.not_sendable_hint') }}</p>
173
+ </div>
174
+
175
+ <div v-if="group && sendable" class="space-y-2">
176
+ <p class="label-lg">{{ $t('send_flow.questions.language') }}</p>
177
+ <p class="text-sm text-muted">
178
+ {{
179
+ $t('send_flow.language.approved_count', { name: group.name, count: approvedCount })
180
+ }}
181
+ </p>
182
+ <LanguageSelect v-model="selectedLanguage" :languages="languages" />
183
+ </div>
184
+ </div>
185
+
186
+ <div v-else class="max-w-md space-y-3">
187
+ <p class="label-lg">{{ $t('send_flow.questions.parameters') }}</p>
188
+ <ParametersForm
189
+ v-model="parameterValues"
190
+ :parameter-keys="parameterKeys"
191
+ :placeholders="placeholders"
192
+ :loading="parametersLoading"
193
+ />
194
+ </div>
195
+ </div>
196
+
197
+ <!-- The preview follows every step: the picked Message, then the typed values. -->
198
+ <aside
199
+ class="flex w-[400px] shrink-0 flex-col items-center gap-2 border-l border-default pl-6"
200
+ :data-testid="messageSendFlowTestIds.previewPanel"
201
+ >
202
+ <p class="label-lg self-start">{{ $t('send_flow.questions.preview') }}</p>
203
+ <WhatsappPreview
204
+ v-if="whatsappMessage && groupId"
205
+ :message="whatsappMessage"
206
+ :message-group-id="groupId"
207
+ :parameters="parameterValues"
208
+ :data-testid="messageSendFlowTestIds.preview"
209
+ />
210
+ <EmailPreview
211
+ v-else-if="selectedMessage && groupId && selectedLanguage"
212
+ :message-group-id="groupId"
213
+ :language="selectedLanguage"
214
+ :parameters="parameterValues"
215
+ :brand-id="brandId"
216
+ />
217
+ <p v-else class="self-start text-sm text-muted">{{ $t('email_preview.no_preview') }}</p>
218
+ </aside>
219
+ </div>
220
+
221
+ <!-- `defer`: a host footer renders after the body, so the target is resolved after this render. -->
222
+ <Teleport :to="actionsTo" :disabled="!actionsTo" defer>
223
+ <div
224
+ class="flex w-full items-center justify-between"
225
+ :class="{ 'border-t border-default pt-4': !actionsTo }"
226
+ :data-testid="messageSendFlowTestIds.actions"
227
+ >
228
+ <div class="flex gap-2">
229
+ <UButton
230
+ variant="ghost"
231
+ color="neutral"
232
+ :label="$t('send_flow.actions.cancel')"
233
+ :data-testid="messageSendFlowTestIds.cancel"
234
+ @click="cancel"
235
+ />
236
+ <UButton
237
+ v-if="canGoBack"
238
+ variant="outline"
239
+ color="neutral"
240
+ :label="$t('send_flow.actions.back')"
241
+ :data-testid="messageSendFlowTestIds.back"
242
+ @click="goBack()"
243
+ />
244
+ </div>
245
+ <UButton
246
+ v-if="!isLastStep"
247
+ :label="$t('send_flow.actions.next')"
248
+ trailing-icon="i-lucide-arrow-right"
249
+ :disabled="!canNext"
250
+ :data-testid="messageSendFlowTestIds.next"
251
+ @click="goNext()"
252
+ />
253
+ <UButton
254
+ v-else
255
+ :label="$t('send_flow.actions.confirm')"
256
+ icon="i-lucide-send"
257
+ :loading="compiling"
258
+ :disabled="!canConfirm"
259
+ :data-testid="messageSendFlowTestIds.confirm"
260
+ @click="confirm()"
261
+ />
262
+ </div>
263
+ </Teleport>
264
+ </div>
265
+ </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>