@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.
- package/README.md +184 -48
- package/app/api/client.ts +12 -0
- package/app/api/compilation.ts +13 -3
- package/app/api/dynamicValues.ts +5 -2
- package/app/api/messageGroups.ts +23 -12
- package/app/components/Common/AppContextErrors.vue +30 -0
- package/app/components/Common/HtmlPreviewIframe.vue +17 -3
- package/app/components/Common/MessageGroupSelect.vue +153 -26
- package/app/components/Common/MessagePreviewSlideover.vue +3 -0
- package/app/components/Composer/MessageLibrary.vue +17 -7
- package/app/components/MessageSendFlow/EmailPreview.vue +78 -0
- package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +252 -0
- package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
- package/app/components/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 +4 -2
- package/app/composables/useMessageGroupList.ts +186 -0
- package/app/composables/useMessageSendFlow.ts +246 -0
- package/app/composables/usePagedMessageGroups.ts +66 -0
- package/app/composables/useTemplateOperations.ts +13 -3
- package/app/layouts/default.vue +1 -0
- package/app/queries/compilation.ts +1 -1
- package/app/queries/dynamicValues.ts +8 -2
- package/app/queries/messageGroups.ts +29 -16
- package/app/stores/appContext.ts +12 -6
- package/app/types/tracking.ts +17 -0
- package/app/utils/testIds.const.ts +29 -0
- package/i18n/locales/de.ts +56 -2
- package/i18n/locales/en.ts +53 -2
- package/i18n/locales/es.ts +54 -2
- package/i18n/locales/fr.ts +53 -2
- package/i18n/locales/it.ts +55 -2
- package/package.json +2 -2
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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="
|
|
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="
|
|
110
|
-
<span class="text-xs text-muted">
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
27
|
+
// List state + server-side paginated query (ADR 0012)
|
|
30
28
|
const {
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
state: listState,
|
|
30
|
+
setFilters,
|
|
31
|
+
setSorting,
|
|
32
|
+
setPage,
|
|
33
|
+
rows: messageGroups,
|
|
34
|
+
meta,
|
|
35
|
+
loading: isFetching,
|
|
33
36
|
refetch: execute,
|
|
34
|
-
} =
|
|
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') }}: </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>
|