@dev.smartpricing/message-composer-layer 4.4.1-next.0 → 4.5.1-next.1
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 +51 -5
- package/app/api/brands.ts +20 -13
- package/app/api/client.ts +3 -6
- package/app/api/dynamicValues.ts +2 -1
- package/app/api/media.ts +6 -3
- package/app/api/messageGroups.ts +6 -2
- 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 +2 -2
- package/app/components/Common/MessageGroupSelect.vue +13 -2
- 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/WabaSelectModal.vue +75 -0
- package/app/components/Composer/WhatsappComposer.vue +78 -3
- package/app/components/Editor/RichTextEditor.vue +14 -2
- package/app/components/MessageSendFlow/EmailPreview.vue +3 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +15 -2
- package/app/components/Whatsapp/Preview.vue +5 -12
- package/app/components/Whatsapp/Translator/Header.vue +4 -19
- package/app/composables/composerContext.ts +5 -5
- package/app/composables/useEffectiveOrganization.ts +34 -0
- package/app/composables/useMessageGroupWaba.ts +73 -0
- package/app/composables/useMessageSendFlow.ts +46 -8
- package/app/composables/usePagedMessageGroups.ts +14 -2
- package/app/composables/useTemplateOperations.ts +10 -1
- package/app/composables/useTracking.ts +14 -5
- package/app/composables/useWhatsappConfiguration.ts +32 -0
- package/app/layouts/default.vue +4 -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 +15 -2
- package/app/queries/dynamicValues.ts +13 -2
- package/app/queries/media.ts +8 -3
- package/app/queries/messageGroups.ts +31 -5
- package/app/queries/meta.ts +13 -8
- package/app/stores/appContext.ts +9 -45
- package/app/stores/effectiveOrganization.ts +128 -0
- package/app/stores/emailEditor.ts +5 -1
- package/app/stores/metaSubmission.ts +7 -17
- package/i18n/locales/de.ts +30 -0
- package/i18n/locales/en.ts +29 -0
- package/i18n/locales/es.ts +29 -0
- package/i18n/locales/fr.ts +30 -0
- package/i18n/locales/it.ts +29 -0
- 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"
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
convertToWhatsappHeaderText,
|
|
14
14
|
convertToWhatsappMarkdown,
|
|
15
15
|
getTipTapExtensions,
|
|
16
|
+
isWhatsappHeaderMultiline,
|
|
16
17
|
sanitizeContent,
|
|
17
18
|
} from '@dev.smartpricing/message-composer-utils/utils'
|
|
18
19
|
import EmojiPickerButton from '../Common/EmojiPickerButton.vue'
|
|
@@ -278,14 +279,21 @@ const toolbarItems = computed(() => {
|
|
|
278
279
|
// WhatsApp markdown-aware character count
|
|
279
280
|
// Meta counts the final markdown output (with *bold*, _italic_ marks),
|
|
280
281
|
// so we must display that count instead of TipTap's document-model count.
|
|
281
|
-
const
|
|
282
|
+
const isWhatsappHeader = computed(() => props.preset === 'whatsapp-header')
|
|
283
|
+
const isWhatsapp = computed(() => props.preset === 'whatsapp' || isWhatsappHeader.value)
|
|
282
284
|
const whatsappCharCount = computed(() => {
|
|
283
285
|
if (!isWhatsapp.value || !props.characterLimit || !text.value) return 0
|
|
284
|
-
return
|
|
286
|
+
return isWhatsappHeader.value
|
|
285
287
|
? convertToWhatsappHeaderText(text.value).length
|
|
286
288
|
: convertToWhatsappMarkdown(text.value).length
|
|
287
289
|
})
|
|
288
290
|
|
|
291
|
+
// A multi-line header is still saved (its lines are joined into one when
|
|
292
|
+
// sent to Meta), so the editor warns instead of raising a validation error.
|
|
293
|
+
const hasHeaderLineBreaks = computed(
|
|
294
|
+
() => isWhatsappHeader.value && isWhatsappHeaderMultiline(text.value),
|
|
295
|
+
)
|
|
296
|
+
|
|
289
297
|
// Handlers
|
|
290
298
|
function onSelectEmoji(emoji: EmojiExt) {
|
|
291
299
|
editor.value?.commands.insertContent(emoji.i)
|
|
@@ -491,6 +499,10 @@ defineExpose({
|
|
|
491
499
|
</span>
|
|
492
500
|
</p>
|
|
493
501
|
|
|
502
|
+
<p v-if="hasHeaderLineBreaks" class="paragraph-xs text-warning-700 pt-1.5 px-1">
|
|
503
|
+
{{ $t('editor.validation.header_line_breaks') }}
|
|
504
|
+
</p>
|
|
505
|
+
|
|
494
506
|
<!-- AI Generate slideover -->
|
|
495
507
|
</div>
|
|
496
508
|
</template>
|
|
@@ -22,6 +22,8 @@ const props = defineProps<{
|
|
|
22
22
|
// The panel follows the form live; one compile per pause, not per keystroke.
|
|
23
23
|
const debouncedParameters = refDebounced(toRef(props, 'parameters'), 300)
|
|
24
24
|
|
|
25
|
+
const organization = useEffectiveOrganizationStore()
|
|
26
|
+
|
|
25
27
|
const { data, isPending, isLoading, error } = useQuery({
|
|
26
28
|
key: () => [
|
|
27
29
|
'send-flow',
|
|
@@ -30,6 +32,7 @@ const { data, isPending, isLoading, error } = useQuery({
|
|
|
30
32
|
props.language,
|
|
31
33
|
props.brandId ?? '',
|
|
32
34
|
JSON.stringify(debouncedParameters.value),
|
|
35
|
+
organization.organizationId,
|
|
33
36
|
],
|
|
34
37
|
query: () =>
|
|
35
38
|
compilationApi.compile(
|
|
@@ -12,7 +12,6 @@ import EmailPreview from './EmailPreview.vue'
|
|
|
12
12
|
import LanguageSelect from './LanguageSelect.vue'
|
|
13
13
|
import ParametersForm from './ParametersForm.vue'
|
|
14
14
|
import { useMessageSendFlow, type MessageSendFlowEmits } from '~/composables/useMessageSendFlow'
|
|
15
|
-
import { useTracking } from '~/composables/useTracking'
|
|
16
15
|
import type { MessageSendFlowStep } from '~/types/tracking'
|
|
17
16
|
import { messageSendFlowTestIds } from '~/utils/testIds.const'
|
|
18
17
|
|
|
@@ -39,6 +38,16 @@ const props = withDefaults(
|
|
|
39
38
|
parameters?: Record<string, string>
|
|
40
39
|
/** Brand override passed to `/compile`. */
|
|
41
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
|
|
42
51
|
/**
|
|
43
52
|
* Where to render the action bar (Cancel, Back, Next, Confirm): a selector
|
|
44
53
|
* or element in the host's container, e.g. a `UModal` footer. Unset, the
|
|
@@ -53,7 +62,6 @@ const groupId = defineModel<string>('messageGroupId')
|
|
|
53
62
|
const emit = defineEmits<MessageSendFlowEmits>()
|
|
54
63
|
|
|
55
64
|
const { t } = useI18n()
|
|
56
|
-
const { trackEvent } = useTracking()
|
|
57
65
|
|
|
58
66
|
const {
|
|
59
67
|
group,
|
|
@@ -70,12 +78,15 @@ const {
|
|
|
70
78
|
compiling,
|
|
71
79
|
canConfirm,
|
|
72
80
|
confirm,
|
|
81
|
+
trackEvent,
|
|
73
82
|
} = useMessageSendFlow({
|
|
74
83
|
groupId,
|
|
75
84
|
renderType: () => props.renderType,
|
|
76
85
|
preferredLanguage: () => props.language,
|
|
77
86
|
prefill: () => props.parameters,
|
|
78
87
|
brandId: () => props.brandId,
|
|
88
|
+
organizationId: () => props.organizationId,
|
|
89
|
+
wabaId: () => props.wabaId,
|
|
79
90
|
emit,
|
|
80
91
|
})
|
|
81
92
|
|
|
@@ -152,6 +163,8 @@ function cancel() {
|
|
|
152
163
|
<MessageGroupSelect
|
|
153
164
|
v-model="groupId"
|
|
154
165
|
:render-type="renderType"
|
|
166
|
+
:organization-id="organizationId"
|
|
167
|
+
:waba-id="wabaId"
|
|
155
168
|
eligibility="sendable"
|
|
156
169
|
class="w-full"
|
|
157
170
|
/>
|
|
@@ -4,12 +4,10 @@ import type {
|
|
|
4
4
|
WaButtonTypeV1,
|
|
5
5
|
WhatsappMessage,
|
|
6
6
|
} from '@dev.smartpricing/message-composer-utils/types'
|
|
7
|
-
import { generateHTML } from '@tiptap/vue-3'
|
|
8
7
|
import { computed } from 'vue'
|
|
9
8
|
import {
|
|
9
|
+
convertToWhatsappHeaderText,
|
|
10
10
|
generateWhatsappHtml,
|
|
11
|
-
getTipTapExtensions,
|
|
12
|
-
sanitizeContent,
|
|
13
11
|
} from '@dev.smartpricing/message-composer-utils/utils'
|
|
14
12
|
import { useResolvedParametersQuery } from '~/queries/compilation'
|
|
15
13
|
|
|
@@ -57,15 +55,10 @@ const bodyHtml = computed(() => {
|
|
|
57
55
|
return replaceMentionsWithValues(html)
|
|
58
56
|
})
|
|
59
57
|
|
|
60
|
-
|
|
58
|
+
// Plain text, exactly as Meta receives it
|
|
59
|
+
const headerText = computed(() => {
|
|
61
60
|
if (props.message.body.header.type !== 'text') return ''
|
|
62
|
-
|
|
63
|
-
const extensions = getTipTapExtensions({ preset: 'whatsapp-header' })
|
|
64
|
-
const html = generateHTML(
|
|
65
|
-
sanitizeContent(props.message.body.header.content, extensions),
|
|
66
|
-
extensions,
|
|
67
|
-
).replaceAll('<p></p>', '<p> </p>')
|
|
68
|
-
return replaceMentionsWithValues(html)
|
|
61
|
+
return replaceMentionsWithValues(convertToWhatsappHeaderText(props.message.body.header.content))
|
|
69
62
|
})
|
|
70
63
|
|
|
71
64
|
const currentTime = computed(() => {
|
|
@@ -131,7 +124,7 @@ const getIcon = (type: WaButtonTypeV1) => {
|
|
|
131
124
|
v-if="message.body.header.type === 'text'"
|
|
132
125
|
class="text-sm text-primary-950 break-all"
|
|
133
126
|
>
|
|
134
|
-
<p
|
|
127
|
+
<p>{{ headerText }}</p>
|
|
135
128
|
</div>
|
|
136
129
|
<div v-else-if="message.body.header.type === 'image'" class="mb-2">
|
|
137
130
|
<img
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { JSONContent } from '@tiptap/vue-3'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
getTipTapExtensions,
|
|
6
|
-
sanitizeContent,
|
|
7
|
-
} from '@dev.smartpricing/message-composer-utils/utils'
|
|
3
|
+
import { convertToWhatsappHeaderText } from '@dev.smartpricing/message-composer-utils/utils'
|
|
8
4
|
import RichTextEditor from '@/components/Editor/RichTextEditor.vue'
|
|
9
5
|
import { useAppContextStore } from '@/stores/appContext'
|
|
10
6
|
import { WA_HEADER_TEXT_MAX_LENGTH } from '@dev.smartpricing/message-composer-utils/types'
|
|
@@ -22,26 +18,15 @@ const targetContent = defineModel<JSONContent | undefined>({
|
|
|
22
18
|
})
|
|
23
19
|
|
|
24
20
|
const appContext = useAppContextStore()
|
|
25
|
-
|
|
26
|
-
// Generate HTML for preview of base language with rich text support
|
|
27
|
-
function generatePreviewHtml(content: JSONContent | undefined): string {
|
|
28
|
-
if (!content) return ''
|
|
29
|
-
const extensions = getTipTapExtensions({ preset: 'whatsapp-header' })
|
|
30
|
-
return generateHTML(sanitizeContent(content, extensions), extensions).replaceAll(
|
|
31
|
-
'<p></p>',
|
|
32
|
-
'<p> </p>',
|
|
33
|
-
)
|
|
34
|
-
}
|
|
35
21
|
</script>
|
|
36
22
|
|
|
37
23
|
<template>
|
|
38
24
|
<div class="flex gap-8 items-start" :data-testid="whatsappTranslatorHeaderTestIds.container">
|
|
39
25
|
<!-- Base language preview (read-only) -->
|
|
40
26
|
<div class="flex-1 min-w-0" :data-testid="whatsappTranslatorHeaderTestIds.basePreview">
|
|
41
|
-
<div
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/>
|
|
27
|
+
<div class="paragraph-sm text-primary-700 break-words">
|
|
28
|
+
{{ convertToWhatsappHeaderText(baseContent) }}
|
|
29
|
+
</div>
|
|
45
30
|
</div>
|
|
46
31
|
|
|
47
32
|
<!-- Translation editor (editable) -->
|
|
@@ -9,19 +9,19 @@ export interface OrganizationData {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export interface ComposerContext {
|
|
12
|
-
/** @deprecated No longer used — auth is cookie-based via platform layer */
|
|
13
|
-
authenticationToken?: Ref<string>
|
|
14
12
|
organization_id?: Ref<string>
|
|
15
13
|
/** The Product the host embeds the composer in (ADR 0011). Required. */
|
|
16
14
|
product: Ref<AppContextConfig['product']>
|
|
17
|
-
strategy?: Ref<AppContextConfig['strategy']>
|
|
18
15
|
channels?: Ref<AppContextConfig['channels']>
|
|
19
16
|
languages?: Ref<string[]>
|
|
20
17
|
mainLanguage?: Ref<string>
|
|
21
|
-
dinamicValues?: Ref<Array<{ value: string; label: string }>>
|
|
22
18
|
default_tags?: Ref<string[]>
|
|
19
|
+
/**
|
|
20
|
+
* The WABA the Host works on. Only a preselection: when the user has to
|
|
21
|
+
* pick the WABA of a Message Group, this one is proposed if it is one of
|
|
22
|
+
* the Organization's (ADR 0015).
|
|
23
|
+
*/
|
|
23
24
|
metaWabaId?: Ref<string>
|
|
24
|
-
metaAccessToken?: Ref<string>
|
|
25
25
|
isAdmin?: Ref<boolean>
|
|
26
26
|
organizationData?: Ref<OrganizationData>
|
|
27
27
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-only view of the Organization the layer is working with, for the Host:
|
|
5
|
+
* e.g. to supply per-Organization `organizationData`. On an editor or preview
|
|
6
|
+
* page it is the Organization of the loaded Message Group, whatever the
|
|
7
|
+
* selector says. `organizationId` is `null` until `status` is `resolved`.
|
|
8
|
+
*/
|
|
9
|
+
export function useEffectiveOrganization() {
|
|
10
|
+
const { organizationId, status } = storeToRefs(useEffectiveOrganizationStore())
|
|
11
|
+
return { organizationId, status }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Makes the Organization of the resource a page views or edits the Effective
|
|
16
|
+
* Organization for as long as the calling component lives, so every dependent
|
|
17
|
+
* query (Brands, images, dynamic values) and everything the page creates
|
|
18
|
+
* follows the resource, not the selector (ADR 0014). Pass the Organization
|
|
19
|
+
* of the loaded resource; nothing is claimed while it is still loading.
|
|
20
|
+
*/
|
|
21
|
+
export function useResourceOrganization(
|
|
22
|
+
organizationId: MaybeRefOrGetter<string | null | undefined>,
|
|
23
|
+
) {
|
|
24
|
+
const store = useEffectiveOrganizationStore()
|
|
25
|
+
const claimant = Symbol('resource-organization')
|
|
26
|
+
|
|
27
|
+
// Sync: whatever the same load triggers next must already see the claim.
|
|
28
|
+
watch(
|
|
29
|
+
() => toValue(organizationId),
|
|
30
|
+
(id) => store.claimResource(claimant, id),
|
|
31
|
+
{ immediate: true, flush: 'sync' },
|
|
32
|
+
)
|
|
33
|
+
onScopeDispose(() => store.releaseResource(claimant))
|
|
34
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { OrganizationWaba } from '@dev.smartpricing/message-composer-utils/types'
|
|
2
|
+
import WabaSelectModal from '~/components/Composer/WabaSelectModal.vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The WABA of the Message Group in the WhatsApp editor (ADR 0015): chosen at
|
|
6
|
+
* the first submission and fixed from then on.
|
|
7
|
+
*
|
|
8
|
+
* `storedWabaId` is the WABA the loaded Message Group is on, if any.
|
|
9
|
+
*/
|
|
10
|
+
export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null | undefined>) {
|
|
11
|
+
const { data: wabas, refresh } = useOrganizationWabasQuery()
|
|
12
|
+
const { metaWabaId } = storeToRefs(useAppContextStore())
|
|
13
|
+
const modal = useOverlay().create(WabaSelectModal)
|
|
14
|
+
|
|
15
|
+
// A submission of one language does not read the Message Group again: its
|
|
16
|
+
// WABA is remembered here until it does.
|
|
17
|
+
const submittedWabaId = ref<string>()
|
|
18
|
+
|
|
19
|
+
const wabaId = computed(() => toValue(storedWabaId) ?? submittedWabaId.value ?? null)
|
|
20
|
+
|
|
21
|
+
/** The WABA the Message Group is on, by id alone when it is not listed. */
|
|
22
|
+
const waba = computed<OrganizationWaba | null>(() => {
|
|
23
|
+
if (!wabaId.value) return null
|
|
24
|
+
return (
|
|
25
|
+
wabas.value?.find((candidate) => candidate.id === wabaId.value) ?? {
|
|
26
|
+
id: wabaId.value,
|
|
27
|
+
name: '',
|
|
28
|
+
phone_numbers: [],
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The Message Group is on a WABA that is no longer one of its
|
|
35
|
+
* Organization's: it cannot be submitted any more. Never true while the
|
|
36
|
+
* lookup is running or failed.
|
|
37
|
+
*/
|
|
38
|
+
const isDetached = computed(
|
|
39
|
+
() =>
|
|
40
|
+
!!wabaId.value &&
|
|
41
|
+
!!wabas.value &&
|
|
42
|
+
!wabas.value.some((candidate) => candidate.id === wabaId.value),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The WABA to name in a submission. Asks the user only at the first
|
|
47
|
+
* submission of a Message Group whose Organization has several WABAs;
|
|
48
|
+
* otherwise names none and the backend resolves it. A lookup that has not
|
|
49
|
+
* answered yet is waited for; one that failed leaves it to the backend.
|
|
50
|
+
* Returns null when the user gives up.
|
|
51
|
+
*/
|
|
52
|
+
async function chooseWaba(): Promise<{ wabaId?: string } | null> {
|
|
53
|
+
if (wabaId.value) return {}
|
|
54
|
+
if (!wabas.value) await refresh()
|
|
55
|
+
const candidates = wabas.value
|
|
56
|
+
if (!candidates || candidates.length < 2) return {}
|
|
57
|
+
|
|
58
|
+
const preselected = candidates.some((candidate) => candidate.id === metaWabaId.value)
|
|
59
|
+
? metaWabaId.value
|
|
60
|
+
: undefined
|
|
61
|
+
const chosen = await modal.open({ wabas: candidates, preselected }).result
|
|
62
|
+
|
|
63
|
+
return chosen ? { wabaId: chosen } : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** After a submission went through, with the WABA it named, if any. */
|
|
67
|
+
function markSubmitted(chosen?: string) {
|
|
68
|
+
if (wabaId.value) return
|
|
69
|
+
submittedWabaId.value = chosen ?? (wabas.value?.length === 1 ? wabas.value[0]!.id : undefined)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { waba, isDetached, chooseWaba, markSubmitted }
|
|
73
|
+
}
|
|
@@ -17,6 +17,7 @@ import { useTracking } from '~/composables/useTracking'
|
|
|
17
17
|
/** Codes a host receives on `error`; the backend ones pass through unchanged. */
|
|
18
18
|
export type MessageSendFlowErrorCode =
|
|
19
19
|
| 'NOT_SENDABLE'
|
|
20
|
+
| 'NOT_OFFERED'
|
|
20
21
|
| 'MISSING_PARAMETERS'
|
|
21
22
|
| 'TEMPLATE_NOT_SUBMITTED'
|
|
22
23
|
| 'TEMPLATE_NOT_APPROVED'
|
|
@@ -64,11 +65,15 @@ export function useMessageSendFlow(options: {
|
|
|
64
65
|
preferredLanguage: MaybeRefOrGetter<string | undefined>
|
|
65
66
|
prefill: MaybeRefOrGetter<Record<string, string> | undefined>
|
|
66
67
|
brandId: MaybeRefOrGetter<string | undefined>
|
|
68
|
+
/** The Organization the flow works on; unset, the one the layer resolved. */
|
|
69
|
+
organizationId?: MaybeRefOrGetter<string | undefined>
|
|
70
|
+
/** The WABA the Host sends from: only WhatsApp groups on it are offered (ADR 0015). */
|
|
71
|
+
wabaId?: MaybeRefOrGetter<string | undefined>
|
|
67
72
|
emit: <K extends keyof MessageSendFlowEmits>(event: K, ...args: MessageSendFlowEmits[K]) => void
|
|
68
73
|
}) {
|
|
69
74
|
const { groupId, emit } = options
|
|
70
75
|
const { t } = useI18n()
|
|
71
|
-
const { trackEvent } = useTracking()
|
|
76
|
+
const { trackEvent } = useTracking({ organizationId: options.organizationId })
|
|
72
77
|
|
|
73
78
|
// --- Group -------------------------------------------------------------
|
|
74
79
|
const { data: group, isLoading: groupLoading } = useMessageGroupQuery(() => groupId.value ?? '', {
|
|
@@ -87,7 +92,23 @@ export function useMessageSendFlow(options: {
|
|
|
87
92
|
})),
|
|
88
93
|
)
|
|
89
94
|
|
|
90
|
-
|
|
95
|
+
// A group reaches the flow by id too (the Host's preselection): one of
|
|
96
|
+
// another Organization, or on another WABA, is not the picker's to offer.
|
|
97
|
+
const offered = computed(() => {
|
|
98
|
+
if (!group.value) return true
|
|
99
|
+
const organizationId = toValue(options.organizationId)
|
|
100
|
+
if (
|
|
101
|
+
organizationId &&
|
|
102
|
+
group.value.organization_id?.toLowerCase() !== organizationId.toLowerCase()
|
|
103
|
+
)
|
|
104
|
+
return false
|
|
105
|
+
const wabaId = toValue(options.wabaId)
|
|
106
|
+
return (
|
|
107
|
+
!wabaId || toValue(options.renderType) !== 'whatsapp_v1' || group.value.waba_id === wabaId
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const sendable = computed(() => offered.value && languages.value.some((l) => l.approved))
|
|
91
112
|
const notSendable = computed(
|
|
92
113
|
() => !!groupId.value && !!group.value && !groupLoading.value && !sendable.value,
|
|
93
114
|
)
|
|
@@ -114,9 +135,16 @@ export function useMessageSendFlow(options: {
|
|
|
114
135
|
|
|
115
136
|
// --- Parameters ----------------------------------------------------------
|
|
116
137
|
const parametersEnabled = () => !!groupId.value && !!selectedLanguage.value
|
|
138
|
+
const organization = useEffectiveOrganizationStore()
|
|
117
139
|
|
|
118
140
|
const { data: parametersData, isLoading: parametersLoading } = useQuery({
|
|
119
|
-
key: () => [
|
|
141
|
+
key: () => [
|
|
142
|
+
'send-flow',
|
|
143
|
+
'parameters',
|
|
144
|
+
groupId.value ?? '',
|
|
145
|
+
selectedLanguage.value ?? '',
|
|
146
|
+
organization.organizationId,
|
|
147
|
+
],
|
|
120
148
|
query: () => compilationApi.getParameters(groupId.value ?? '', selectedLanguage.value ?? ''),
|
|
121
149
|
enabled: parametersEnabled,
|
|
122
150
|
})
|
|
@@ -170,17 +198,26 @@ export function useMessageSendFlow(options: {
|
|
|
170
198
|
emit('error', error)
|
|
171
199
|
}
|
|
172
200
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
// The reason a picked group cannot enter the flow. Watched with the group,
|
|
202
|
+
// so another group, or another reason for the same one, is reported anew.
|
|
203
|
+
const groupError = computed<MessageSendFlowErrorCode | undefined>(() => {
|
|
204
|
+
if (!notSendable.value) return undefined
|
|
205
|
+
return offered.value ? 'NOT_SENDABLE' : 'NOT_OFFERED'
|
|
206
|
+
})
|
|
207
|
+
watch([groupError, () => group.value?.id], ([code]) => {
|
|
208
|
+
if (code) {
|
|
209
|
+
fail(code)
|
|
210
|
+
} else if (
|
|
211
|
+
flowError.value?.code === 'NOT_SENDABLE' ||
|
|
212
|
+
flowError.value?.code === 'NOT_OFFERED'
|
|
213
|
+
) {
|
|
177
214
|
flowError.value = undefined
|
|
178
215
|
}
|
|
179
216
|
})
|
|
180
217
|
|
|
181
218
|
// --- Compile (once, on Confirm) --------------------------------------------
|
|
182
219
|
const compiling = ref(false)
|
|
183
|
-
const canConfirm = computed(() => !!selectedMessage.value && !compiling.value)
|
|
220
|
+
const canConfirm = computed(() => sendable.value && !!selectedMessage.value && !compiling.value)
|
|
184
221
|
|
|
185
222
|
async function confirm() {
|
|
186
223
|
flowError.value = undefined
|
|
@@ -242,5 +279,6 @@ export function useMessageSendFlow(options: {
|
|
|
242
279
|
compiling,
|
|
243
280
|
canConfirm,
|
|
244
281
|
confirm,
|
|
282
|
+
trackEvent,
|
|
245
283
|
}
|
|
246
284
|
}
|
|
@@ -23,6 +23,8 @@ export interface PagedMessageGroupsOptions {
|
|
|
23
23
|
resetOn?: WatchSource[]
|
|
24
24
|
/** When false no request is made (the query stays pending). */
|
|
25
25
|
enabled?: MaybeRefOrGetter<boolean>
|
|
26
|
+
/** Lists this Organization instead of the resolved one. */
|
|
27
|
+
organizationId?: MaybeRefOrGetter<string | undefined>
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -42,12 +44,22 @@ export function usePagedMessageGroups(options: PagedMessageGroupsOptions) {
|
|
|
42
44
|
per_page: options.perPage,
|
|
43
45
|
}))
|
|
44
46
|
|
|
45
|
-
const query = useMessageGroupListQuery(listOptions, {
|
|
47
|
+
const query = useMessageGroupListQuery(listOptions, {
|
|
48
|
+
enabled: options.enabled,
|
|
49
|
+
organizationId: options.organizationId,
|
|
50
|
+
})
|
|
46
51
|
|
|
47
52
|
// The reset happens in the same tick as the change (sync flush) so the query
|
|
48
53
|
// sees one key change, not two, and the user never opens an empty page.
|
|
54
|
+
const organization = useEffectiveOrganizationStore()
|
|
49
55
|
watch(
|
|
50
|
-
[
|
|
56
|
+
[
|
|
57
|
+
debouncedSearch,
|
|
58
|
+
() => toValue(options.renderType),
|
|
59
|
+
// Another Organization is another list: its page N may not exist.
|
|
60
|
+
() => toValue(options.organizationId) || organization.resolvedOrganizationId,
|
|
61
|
+
...(options.resetOn ?? []),
|
|
62
|
+
],
|
|
51
63
|
() => {
|
|
52
64
|
options.page.value = 1
|
|
53
65
|
},
|
|
@@ -29,6 +29,7 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
|
|
|
29
29
|
const saving = ref(false)
|
|
30
30
|
const saved = ref(false)
|
|
31
31
|
const appContext = useAppContextStore()
|
|
32
|
+
const organization = useEffectiveOrganizationStore()
|
|
32
33
|
|
|
33
34
|
// Hoist composables to setup scope (they use inject() internally)
|
|
34
35
|
const { mutateAsync: createMessageGroup } = useCreateMessageGroupMutation()
|
|
@@ -51,6 +52,9 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
|
|
|
51
52
|
const createdMessageGroup = await createMessageGroup({
|
|
52
53
|
...data.messageGroup,
|
|
53
54
|
product,
|
|
55
|
+
// The Effective Organization (ADR 0014): the selected one, or the
|
|
56
|
+
// edited group's on "save as new".
|
|
57
|
+
organization_id: organization.requireOrganizationId(),
|
|
54
58
|
} as PostEntity<MessageGroup>)
|
|
55
59
|
|
|
56
60
|
if (createdMessageGroup?.id) {
|
|
@@ -112,7 +116,12 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
|
|
|
112
116
|
try {
|
|
113
117
|
const result = await duplicateMessageGroup({
|
|
114
118
|
id: sourceId,
|
|
115
|
-
data:
|
|
119
|
+
data: {
|
|
120
|
+
...(newName && { name: newName }),
|
|
121
|
+
// The Effective Organization (ADR 0014): the selected one, or the
|
|
122
|
+
// edited group's on "save as new".
|
|
123
|
+
organization_id: organization.requireOrganizationId(),
|
|
124
|
+
},
|
|
116
125
|
})
|
|
117
126
|
|
|
118
127
|
saved.value = true
|