@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.
- package/README.md +228 -46
- package/app/api/brands.ts +20 -13
- package/app/api/client.ts +15 -6
- package/app/api/compilation.ts +13 -3
- package/app/api/dynamicValues.ts +6 -2
- package/app/api/media.ts +6 -3
- package/app/api/messageGroups.ts +29 -14
- package/app/api/meta.ts +26 -4
- package/app/api/organization.ts +10 -1
- package/app/api/tracking.ts +2 -2
- package/app/components/Common/AppContextErrors.vue +30 -0
- package/app/components/Common/HtmlPreviewIframe.vue +17 -3
- package/app/components/Common/MessageGroupSelect.vue +164 -26
- package/app/components/Common/MessagePreviewSlideover.vue +3 -0
- package/app/components/Common/OrganizationGate.vue +51 -0
- package/app/components/Common/OrganizationSelect.vue +27 -0
- package/app/components/Common/WhatsappNotConfigured.vue +20 -0
- package/app/components/Composer/EmailComposer.vue +4 -0
- package/app/components/Composer/MessageLibrary.vue +17 -7
- package/app/components/Composer/WabaSelectModal.vue +75 -0
- package/app/components/Composer/WhatsappComposer.vue +78 -3
- package/app/components/MessageSendFlow/EmailPreview.vue +81 -0
- package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +265 -0
- package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
- package/app/components/TemplateList/BaseTable.vue +156 -108
- package/app/components/Whatsapp/Preview.vue +12 -4
- package/app/components/Whatsapp/PreviewPanel.vue +7 -1
- package/app/composables/composerContext.ts +9 -7
- package/app/composables/useEffectiveOrganization.ts +34 -0
- package/app/composables/useMessageGroupList.ts +186 -0
- package/app/composables/useMessageGroupWaba.ts +73 -0
- package/app/composables/useMessageSendFlow.ts +284 -0
- package/app/composables/usePagedMessageGroups.ts +78 -0
- package/app/composables/useTemplateOperations.ts +23 -4
- package/app/composables/useTracking.ts +14 -5
- package/app/composables/useWhatsappConfiguration.ts +32 -0
- package/app/layouts/default.vue +5 -1
- package/app/pages/brands.vue +1 -0
- package/app/pages/images.vue +4 -0
- package/app/pages/index.vue +1 -0
- package/app/pages/preview/[id].vue +2 -0
- package/app/pages/template/whatsapp/[id].vue +1 -0
- package/app/queries/brands.ts +27 -7
- package/app/queries/compilation.ts +16 -3
- package/app/queries/dynamicValues.ts +19 -2
- package/app/queries/media.ts +8 -3
- package/app/queries/messageGroups.ts +56 -17
- package/app/queries/meta.ts +13 -8
- package/app/stores/appContext.ts +18 -48
- package/app/stores/effectiveOrganization.ts +128 -0
- package/app/stores/emailEditor.ts +5 -1
- package/app/stores/metaSubmission.ts +7 -17
- package/app/types/tracking.ts +17 -0
- package/app/utils/testIds.const.ts +29 -0
- package/i18n/locales/de.ts +84 -2
- package/i18n/locales/en.ts +80 -2
- package/i18n/locales/es.ts +81 -2
- package/i18n/locales/fr.ts +81 -2
- package/i18n/locales/it.ts +82 -2
- 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') }}: </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>
|