@dev.smartpricing/message-composer-layer 4.3.2-fix.1 → 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.
Files changed (35) hide show
  1. package/README.md +184 -48
  2. package/app/api/client.ts +12 -0
  3. package/app/api/compilation.ts +13 -3
  4. package/app/api/dynamicValues.ts +5 -2
  5. package/app/api/messageGroups.ts +23 -12
  6. package/app/components/Common/AppContextErrors.vue +30 -0
  7. package/app/components/Common/HtmlPreviewIframe.vue +17 -3
  8. package/app/components/Common/MessageGroupSelect.vue +153 -26
  9. package/app/components/Common/MessagePreviewSlideover.vue +3 -0
  10. package/app/components/Composer/MessageLibrary.vue +17 -7
  11. package/app/components/MessageSendFlow/EmailPreview.vue +78 -0
  12. package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
  13. package/app/components/MessageSendFlow/MessageSendFlow.vue +252 -0
  14. package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
  15. package/app/components/TemplateList/BaseTable.vue +156 -108
  16. package/app/components/Whatsapp/Preview.vue +12 -4
  17. package/app/components/Whatsapp/PreviewPanel.vue +7 -1
  18. package/app/composables/composerContext.ts +4 -2
  19. package/app/composables/useMessageGroupList.ts +186 -0
  20. package/app/composables/useMessageSendFlow.ts +246 -0
  21. package/app/composables/usePagedMessageGroups.ts +66 -0
  22. package/app/composables/useTemplateOperations.ts +13 -3
  23. package/app/layouts/default.vue +1 -0
  24. package/app/queries/compilation.ts +1 -1
  25. package/app/queries/dynamicValues.ts +8 -2
  26. package/app/queries/messageGroups.ts +29 -16
  27. package/app/stores/appContext.ts +12 -6
  28. package/app/types/tracking.ts +17 -0
  29. package/app/utils/testIds.const.ts +29 -0
  30. package/i18n/locales/de.ts +56 -2
  31. package/i18n/locales/en.ts +53 -2
  32. package/i18n/locales/es.ts +54 -2
  33. package/i18n/locales/fr.ts +53 -2
  34. package/i18n/locales/it.ts +55 -2
  35. package/package.json +2 -2
@@ -1,9 +1,41 @@
1
1
  <script setup lang="ts">
2
- import type { RenderType, MessageStatus } from '@dev.smartpricing/message-composer-utils/types'
3
- import { useMessageGroupsQuery } from '~/queries/messageGroups'
2
+ import type { MessageStatus, RenderType } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { usePagedMessageGroups } from '~/composables/usePagedMessageGroups'
4
+ import { useMessageGroupQuery } from '~/queries/messageGroups'
5
+ import { useAppContextStore } from '~/stores/appContext'
4
6
 
5
7
  defineOptions({ name: 'MessageGroupSelect' })
6
8
 
9
+ type MessageGroupEligibility = 'active' | 'sendable'
10
+
11
+ /** Per mode: the selectability rule and the copy shown for groups that fail it. */
12
+ const ELIGIBILITY_RULES: Record<
13
+ MessageGroupEligibility,
14
+ {
15
+ isEligible: (messages: Array<{ status: MessageStatus }>) => boolean
16
+ tooltipKey: string
17
+ hideFilterKey: string
18
+ }
19
+ > = {
20
+ active: {
21
+ isEligible: (messages) => messages.length > 0 && messages.every((m) => m.status === 'approved'),
22
+ tooltipKey: 'common.messageGroupSelect.inactive_tooltip',
23
+ hideFilterKey: 'common.messageGroupSelect.hide_inactive',
24
+ },
25
+ sendable: {
26
+ isEligible: (messages) => messages.some((m) => m.status === 'approved'),
27
+ tooltipKey: 'common.messageGroupSelect.not_sendable_tooltip',
28
+ hideFilterKey: 'common.messageGroupSelect.hide_not_sendable',
29
+ },
30
+ }
31
+
32
+ /**
33
+ * In-composer Message Group picker (ADR 0012). Always lists the context
34
+ * Product's groups only: search is server-side, debounced and bound to the
35
+ * menu's search input; pages of 100 load on demand through "Load more".
36
+ * The selected group stays in the menu even when the loaded pages no longer
37
+ * contain it (search cleared, paging reset, or preselected beyond page one).
38
+ */
7
39
  const props = withDefaults(
8
40
  defineProps<{
9
41
  renderType?: RenderType
@@ -21,10 +53,18 @@ const props = withDefaults(
21
53
  disabledGroupReasons?: Record<string, string>
22
54
  /** Overrides the "hide inactive" filter label. Defaults to the built-in translation. */
23
55
  hideFilterLabel?: string
56
+ /**
57
+ * Which groups are selectable: `active` (every Message approved, the
58
+ * composer's own rule) or `sendable` (at least one approved Message, the
59
+ * Message Send Flow's rule). The disabled tooltip and the hide filter
60
+ * follow the mode.
61
+ */
62
+ eligibility?: MessageGroupEligibility
24
63
  }>(),
25
64
  {
26
65
  placeholder: 'Select a message group',
27
66
  searchPlaceholder: 'Search...',
67
+ eligibility: 'active',
28
68
  },
29
69
  )
30
70
 
@@ -34,20 +74,95 @@ const emit = defineEmits<{
34
74
  select: [item: { id: string; name: string }]
35
75
  }>()
36
76
 
77
+ /** What the picker needs from a group, whichever endpoint it came from. */
78
+ interface PickerGroup {
79
+ id: string
80
+ name: string
81
+ messages: Array<{ status: MessageStatus }>
82
+ }
83
+
84
+ const PER_PAGE = 100
85
+
37
86
  const { t } = useI18n()
87
+ const appContext = useAppContextStore()
38
88
 
39
- const { data: messageGroups, isLoading } = useMessageGroupsQuery(() => ({
40
- renderType: props.renderType,
41
- }))
89
+ // Server-side search: the menu's own client filter is off (`ignore-filter`),
90
+ // the typed term reaches the list request once it settles. A new search,
91
+ // render type or context Product starts over from page one.
92
+ const searchTerm = ref('')
93
+ const page = ref(1)
42
94
 
43
- const hideInactive = ref(false)
95
+ // Without a context Product the server would answer with every Product's
96
+ // groups (ADR 0011): never ask. The host's missing Product is already reported
97
+ // by the app-context validation.
98
+ const {
99
+ query: { data: envelope, isPlaceholderData },
100
+ loading,
101
+ } = usePagedMessageGroups({
102
+ search: searchTerm,
103
+ page,
104
+ perPage: PER_PAGE,
105
+ renderType: () => props.renderType,
106
+ filters: () => ({
107
+ product: appContext.product,
108
+ visibility: appContext.isAdmin ? 'all' : 'public',
109
+ }),
110
+ resetOn: [() => appContext.product],
111
+ enabled: () => appContext.product != null,
112
+ })
44
113
 
45
- function isGroupActive(messages: Array<{ status: MessageStatus }>): boolean {
46
- return messages.length > 0 && messages.every((m) => m.status === 'approved')
47
- }
114
+ // Pages loaded so far, indexed by page number: page one replaces everything,
115
+ // later pages slot in at their index (a refetch replaces, never duplicates).
116
+ const pages = shallowRef<PickerGroup[][]>([])
117
+ watch(
118
+ envelope,
119
+ (value) => {
120
+ if (!value) return
121
+ const index = value.meta.current_page - 1
122
+ pages.value = index === 0 ? [value.data] : [...pages.value.slice(0, index), value.data]
123
+ },
124
+ { immediate: true },
125
+ )
126
+ const loadedGroups = computed(() => pages.value.flat())
127
+
128
+ // The selected group is pinned on top of the loaded pages when they do not
129
+ // contain it: remembered from the last pick, or fetched by id when preselected.
130
+ const lastPicked = shallowRef<PickerGroup>()
131
+ const missingSelectedId = computed(() => {
132
+ const id = modelValue.value
133
+ if (!id || loadedGroups.value.some((g) => g.id === id)) return undefined
134
+ return lastPicked.value?.id === id ? undefined : id
135
+ })
136
+ const { data: fetchedSelected } = useMessageGroupQuery(() => missingSelectedId.value ?? '', {
137
+ enabled: () => missingSelectedId.value != null,
138
+ })
139
+ const messageGroups = computed<PickerGroup[]>(() => {
140
+ const id = modelValue.value
141
+ if (!id || loadedGroups.value.some((g) => g.id === id)) return loadedGroups.value
142
+ const pinned =
143
+ lastPicked.value?.id === id
144
+ ? lastPicked.value
145
+ : fetchedSelected.value?.id === id
146
+ ? fetchedSelected.value
147
+ : undefined
148
+ return pinned ? [pinned, ...loadedGroups.value] : loadedGroups.value
149
+ })
150
+
151
+ const hasMore = computed(
152
+ () =>
153
+ !isPlaceholderData.value &&
154
+ envelope.value != null &&
155
+ envelope.value.meta.current_page < envelope.value.meta.last_page,
156
+ )
157
+ const isLoadingFirstPage = computed(() => page.value === 1 && loading.value)
158
+ const isLoadingMore = computed(() => page.value > 1 && loading.value)
159
+
160
+ const hideIneligible = ref(false)
161
+
162
+ const rule = computed(() => ELIGIBILITY_RULES[props.eligibility])
163
+ const hideFilterLabel = computed(() => props.hideFilterLabel ?? t(rule.value.hideFilterKey))
48
164
 
49
165
  const items = computed(() => {
50
- if (!messageGroups.value) return []
51
166
  return messageGroups.value.reduce<
52
167
  Array<{
53
168
  label: string
@@ -58,20 +173,18 @@ const items = computed(() => {
58
173
  }>
59
174
  >((acc, group) => {
60
175
  const externalReason = props.disabledGroupReasons?.[group.id]
61
- const externallyUsed = externalReason != null
62
- const active = isGroupActive(group.messages)
63
- const disabled = !active || externallyUsed
64
- if (hideInactive.value && disabled) return acc
176
+ const eligible = rule.value.isEligible(group.messages)
177
+ const disabled = !eligible || externalReason != null
178
+ if (hideIneligible.value && disabled) return acc
179
+ // External reason wins over the mode's own tooltip when both apply.
180
+ let tooltip: string | undefined
181
+ if (externalReason != null) tooltip = externalReason
182
+ else if (!eligible) tooltip = t(rule.value.tooltipKey)
65
183
  acc.push({
66
184
  label: group.name,
67
185
  value: group.id,
68
186
  disabled,
69
- // External reason wins over the inactive tooltip when both apply.
70
- tooltip: externallyUsed
71
- ? externalReason
72
- : !active
73
- ? t('common.messageGroupSelect.inactive_tooltip')
74
- : undefined,
187
+ tooltip,
75
188
  chip: { color: disabled ? 'neutral' : 'success' },
76
189
  })
77
190
  return acc
@@ -80,18 +193,21 @@ const items = computed(() => {
80
193
 
81
194
  function handleUpdate(value: string) {
82
195
  modelValue.value = value
83
- const group = messageGroups.value?.find((g) => g.id === value)
196
+ const group = messageGroups.value.find((g) => g.id === value)
197
+ lastPicked.value = group
84
198
  emit('select', { id: value, name: group?.name ?? '' })
85
199
  }
86
200
  </script>
87
201
 
88
202
  <template>
89
203
  <USelectMenu
204
+ v-model:search-term="searchTerm"
90
205
  :model-value="modelValue"
91
206
  :items="items"
92
207
  value-key="value"
93
- :loading="isLoading"
208
+ :loading="isLoadingFirstPage"
94
209
  searchable
210
+ ignore-filter
95
211
  :placeholder="placeholder"
96
212
  :search-input="{ placeholder: searchPlaceholder }"
97
213
  :data-testid="messageGroupSelectTestIds.root"
@@ -106,10 +222,21 @@ function handleUpdate(value: string) {
106
222
 
107
223
  <template #content-top>
108
224
  <div class="flex items-center gap-2 px-2 py-1.5 border-b border-default">
109
- <USwitch v-model="hideInactive" size="xs" />
110
- <span class="text-xs text-muted">
111
- {{ hideFilterLabel ?? t('common.messageGroupSelect.hide_inactive') }}
112
- </span>
225
+ <USwitch v-model="hideIneligible" size="xs" />
226
+ <span class="text-xs text-muted">{{ hideFilterLabel }}</span>
227
+ </div>
228
+ </template>
229
+
230
+ <template v-if="hasMore || isLoadingMore" #content-bottom>
231
+ <div class="flex justify-center px-2 py-1.5 border-t border-default">
232
+ <UButton
233
+ variant="link"
234
+ size="xs"
235
+ :loading="isLoadingMore"
236
+ :label="t('common.messageGroupSelect.load_more')"
237
+ :data-testid="messageGroupSelectTestIds.loadMore"
238
+ @click="page++"
239
+ />
113
240
  </div>
114
241
  </template>
115
242
  </USelectMenu>
@@ -14,6 +14,8 @@ import { useEmailEditorStore } from '~/stores/emailEditor'
14
14
  interface Props {
15
15
  message: PostEntity<Message> | MessageFromDb | null | undefined
16
16
  messageGroupId?: string
17
+ /** WhatsApp only, forwarded to `WhatsappPreview`: operator-typed values overriding the resolved ones. No effect on the email preview. */
18
+ parameters?: Record<string, string>
17
19
  }
18
20
 
19
21
  const props = defineProps<Props>()
@@ -72,6 +74,7 @@ const isEmail = computed(() => renderType.value === 'email_v1')
72
74
  v-if="isWhatsapp && messageGroupId"
73
75
  :message="message as WhatsappMessage"
74
76
  :message-group-id="messageGroupId"
77
+ :parameters="parameters"
75
78
  />
76
79
 
77
80
  <!-- Email Inline Preview (client-driven, reflects unsaved changes) -->
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
- useMessageGroupsQuery,
4
3
  useDeleteMessageGroupMutation,
5
4
  usePublishMessageGroupMutation,
6
5
  useUnpublishMessageGroupMutation,
@@ -10,7 +9,6 @@ import type {
10
9
  RenderType,
11
10
  } from '@dev.smartpricing/message-composer-utils/types'
12
11
  import type { MessageActionProperties, MessagesKind } from '@/types/tracking'
13
- import { computed } from 'vue'
14
12
  import BaseTable from '@/components/TemplateList/BaseTable.vue'
15
13
 
16
14
  const renderType = defineModel<RenderType>('renderType', { required: true })
@@ -26,12 +24,17 @@ const emit = defineEmits<{
26
24
  const { trackEvent } = useTracking()
27
25
  const { t } = useI18n()
28
26
 
29
- // Query
27
+ // List state + server-side paginated query (ADR 0012)
30
28
  const {
31
- data: messageGroups,
32
- isPending: isFetching,
29
+ state: listState,
30
+ setFilters,
31
+ setSorting,
32
+ setPage,
33
+ rows: messageGroups,
34
+ meta,
35
+ loading: isFetching,
33
36
  refetch: execute,
34
- } = useMessageGroupsQuery(computed(() => ({ renderType: renderType.value })))
37
+ } = useMessageGroupList(renderType)
35
38
 
36
39
  // Polling
37
40
  const interval = setInterval(() => execute(), 15000)
@@ -144,8 +147,15 @@ defineExpose({ execute })
144
147
  <template>
145
148
  <BaseTable
146
149
  :loading="isFetching"
147
- :message-groups="messageGroups || []"
150
+ :message-groups="messageGroups"
151
+ :meta="meta"
148
152
  :render-type="renderType"
153
+ :filters="listState"
154
+ :sorting="listState"
155
+ :page="listState.page"
156
+ @update:filters="setFilters"
157
+ @update:sorting="setSorting"
158
+ @update:page="setPage"
149
159
  @edit="navigate"
150
160
  @delete="askDeleteGroup"
151
161
  @duplicate="handleDuplicate"
@@ -0,0 +1,78 @@
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 { data, isPending, isLoading, error } = useQuery({
26
+ key: () => [
27
+ 'send-flow',
28
+ 'email-preview',
29
+ props.messageGroupId,
30
+ props.language,
31
+ props.brandId ?? '',
32
+ JSON.stringify(debouncedParameters.value),
33
+ ],
34
+ query: () =>
35
+ compilationApi.compile(
36
+ props.messageGroupId,
37
+ {
38
+ language: props.language,
39
+ parameters: { ...debouncedParameters.value },
40
+ strict: false,
41
+ strict_language: true,
42
+ brand_id: props.brandId,
43
+ },
44
+ { skipErrorToast: true },
45
+ ),
46
+ // Keep the last email on screen while the next one compiles: no skeleton, no blank.
47
+ placeholderData: (previousData) => previousData,
48
+ })
49
+
50
+ const compiled = computed(() => (data.value?.render_type === 'email_v1' ? data.value : undefined))
51
+ </script>
52
+
53
+ <template>
54
+ <div class="w-full max-w-[600px]" :data-testid="messageSendFlowTestIds.emailPreview">
55
+ <div v-if="isPending && !compiled" class="space-y-2">
56
+ <USkeleton class="h-6 w-1/3" />
57
+ <USkeleton class="h-4" />
58
+ <USkeleton class="h-32" />
59
+ </div>
60
+ <div
61
+ v-else-if="compiled"
62
+ class="transition-opacity duration-200"
63
+ :class="{ 'opacity-60': isLoading }"
64
+ >
65
+ <p class="mb-2">
66
+ <span class="paragraph-sm-bold">{{ $t('email_preview.subject') }}:&nbsp;</span>
67
+ <span class="paragraph-sm">{{ compiled.subject }}</span>
68
+ </p>
69
+ <HtmlPreviewIframe
70
+ :html="compiled.html"
71
+ title="Email preview"
72
+ :min-height="480"
73
+ class="w-full rounded-lg border border-default bg-white"
74
+ />
75
+ </div>
76
+ <p v-else-if="error" class="text-sm text-muted">{{ $t('email_preview.no_preview') }}</p>
77
+ </div>
78
+ </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,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>