@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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAppContextStore } from '~/stores/appContext'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Host-integration problems reported by the app-context store (missing
|
|
6
|
+
* Product, ...). Shown persistently: the host has to fix its context, the
|
|
7
|
+
* user cannot.
|
|
8
|
+
*/
|
|
9
|
+
const { validationErrors } = storeToRefs(useAppContextStore())
|
|
10
|
+
const messages = computed(() => Object.values(validationErrors.value))
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<UAlert
|
|
15
|
+
v-if="messages.length"
|
|
16
|
+
color="error"
|
|
17
|
+
variant="subtle"
|
|
18
|
+
icon="i-lucide-alert-triangle"
|
|
19
|
+
:title="$t('common.context_error_title')"
|
|
20
|
+
class="rounded-none"
|
|
21
|
+
>
|
|
22
|
+
<template #description>
|
|
23
|
+
<ul class="list-disc pl-4 space-y-1">
|
|
24
|
+
<li v-for="message in messages" :key="message">
|
|
25
|
+
{{ message }}
|
|
26
|
+
</li>
|
|
27
|
+
</ul>
|
|
28
|
+
</template>
|
|
29
|
+
</UAlert>
|
|
30
|
+
</template>
|
|
@@ -17,6 +17,8 @@ const props = withDefaults(
|
|
|
17
17
|
|
|
18
18
|
const iframeRef = useTemplateRef<HTMLIFrameElement>('iframe')
|
|
19
19
|
const measuredHeight = ref(0)
|
|
20
|
+
// The first document comes from `srcdoc`; later ones are written in place (see the watch).
|
|
21
|
+
const initialHtml = props.html
|
|
20
22
|
|
|
21
23
|
const heightStyle = computed(() => {
|
|
22
24
|
if (!measuredHeight.value) return `${props.minHeight}px`
|
|
@@ -44,10 +46,22 @@ function onLoad() {
|
|
|
44
46
|
observeContent()
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Replace the document in place instead of re-binding `srcdoc`: a `srcdoc`
|
|
51
|
+
* change is a navigation, which blanks the frame and drops the height for a
|
|
52
|
+
* few frames. Writing keeps the frame painted and the last measured height
|
|
53
|
+
* until the new content is measured.
|
|
54
|
+
*/
|
|
47
55
|
watch(
|
|
48
56
|
() => props.html,
|
|
49
|
-
() => {
|
|
50
|
-
|
|
57
|
+
(html) => {
|
|
58
|
+
const doc = iframeRef.value?.contentDocument
|
|
59
|
+
if (!doc) return
|
|
60
|
+
doc.open()
|
|
61
|
+
doc.write(html)
|
|
62
|
+
doc.close()
|
|
63
|
+
fitToContent()
|
|
64
|
+
observeContent()
|
|
51
65
|
},
|
|
52
66
|
)
|
|
53
67
|
|
|
@@ -59,7 +73,7 @@ onBeforeUnmount(() => {
|
|
|
59
73
|
<template>
|
|
60
74
|
<iframe
|
|
61
75
|
ref="iframe"
|
|
62
|
-
:srcdoc="
|
|
76
|
+
:srcdoc="initialHtml"
|
|
63
77
|
:title="title"
|
|
64
78
|
sandbox="allow-same-origin"
|
|
65
79
|
:style="{ height: heightStyle }"
|
|
@@ -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,22 @@ 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
|
|
63
|
+
/** Lists this Organization's groups instead of the resolved Organization's. */
|
|
64
|
+
organizationId?: string
|
|
65
|
+
/** Lists only the WhatsApp groups on this WABA (ADR 0015); needs `renderType: 'whatsapp_v1'`. */
|
|
66
|
+
wabaId?: string
|
|
24
67
|
}>(),
|
|
25
68
|
{
|
|
26
69
|
placeholder: 'Select a message group',
|
|
27
70
|
searchPlaceholder: 'Search...',
|
|
71
|
+
eligibility: 'active',
|
|
28
72
|
},
|
|
29
73
|
)
|
|
30
74
|
|
|
@@ -34,20 +78,102 @@ const emit = defineEmits<{
|
|
|
34
78
|
select: [item: { id: string; name: string }]
|
|
35
79
|
}>()
|
|
36
80
|
|
|
81
|
+
/** What the picker needs from a group, whichever endpoint it came from. */
|
|
82
|
+
interface PickerGroup {
|
|
83
|
+
id: string
|
|
84
|
+
name: string
|
|
85
|
+
messages: Array<{ status: MessageStatus }>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PER_PAGE = 100
|
|
89
|
+
|
|
37
90
|
const { t } = useI18n()
|
|
91
|
+
const appContext = useAppContextStore()
|
|
38
92
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
93
|
+
// Server-side search: the menu's own client filter is off (`ignore-filter`),
|
|
94
|
+
// the typed term reaches the list request once it settles. A new search,
|
|
95
|
+
// render type, context Product or WABA starts over from page one.
|
|
96
|
+
const searchTerm = ref('')
|
|
97
|
+
const page = ref(1)
|
|
42
98
|
|
|
43
|
-
|
|
99
|
+
// Without a context Product the server would answer with every Product's
|
|
100
|
+
// groups (ADR 0011): never ask. The host's missing Product is already reported
|
|
101
|
+
// by the app-context validation.
|
|
102
|
+
// Only WhatsApp groups are on a WABA: with any other render type the filter would empty the list.
|
|
103
|
+
const wabaFilter = computed(() =>
|
|
104
|
+
props.renderType === 'whatsapp_v1' ? props.wabaId || undefined : undefined,
|
|
105
|
+
)
|
|
44
106
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
107
|
+
const {
|
|
108
|
+
query: { data: envelope, isPlaceholderData },
|
|
109
|
+
loading,
|
|
110
|
+
} = usePagedMessageGroups({
|
|
111
|
+
search: searchTerm,
|
|
112
|
+
page,
|
|
113
|
+
perPage: PER_PAGE,
|
|
114
|
+
renderType: () => props.renderType,
|
|
115
|
+
filters: () => ({
|
|
116
|
+
product: appContext.product,
|
|
117
|
+
visibility: appContext.isAdmin ? 'all' : 'public',
|
|
118
|
+
waba_id: wabaFilter.value,
|
|
119
|
+
}),
|
|
120
|
+
resetOn: [() => appContext.product, wabaFilter],
|
|
121
|
+
enabled: () => appContext.product != null,
|
|
122
|
+
organizationId: () => props.organizationId,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Pages loaded so far, indexed by page number: page one replaces everything,
|
|
126
|
+
// later pages slot in at their index (a refetch replaces, never duplicates).
|
|
127
|
+
const pages = shallowRef<PickerGroup[][]>([])
|
|
128
|
+
watch(
|
|
129
|
+
envelope,
|
|
130
|
+
(value) => {
|
|
131
|
+
if (!value) return
|
|
132
|
+
const index = value.meta.current_page - 1
|
|
133
|
+
pages.value = index === 0 ? [value.data] : [...pages.value.slice(0, index), value.data]
|
|
134
|
+
},
|
|
135
|
+
{ immediate: true },
|
|
136
|
+
)
|
|
137
|
+
const loadedGroups = computed(() => pages.value.flat())
|
|
138
|
+
|
|
139
|
+
// The selected group is pinned on top of the loaded pages when they do not
|
|
140
|
+
// contain it: remembered from the last pick, or fetched by id when preselected.
|
|
141
|
+
const lastPicked = shallowRef<PickerGroup>()
|
|
142
|
+
const missingSelectedId = computed(() => {
|
|
143
|
+
const id = modelValue.value
|
|
144
|
+
if (!id || loadedGroups.value.some((g) => g.id === id)) return undefined
|
|
145
|
+
return lastPicked.value?.id === id ? undefined : id
|
|
146
|
+
})
|
|
147
|
+
const { data: fetchedSelected } = useMessageGroupQuery(() => missingSelectedId.value ?? '', {
|
|
148
|
+
enabled: () => missingSelectedId.value != null,
|
|
149
|
+
})
|
|
150
|
+
const messageGroups = computed<PickerGroup[]>(() => {
|
|
151
|
+
const id = modelValue.value
|
|
152
|
+
if (!id || loadedGroups.value.some((g) => g.id === id)) return loadedGroups.value
|
|
153
|
+
const pinned =
|
|
154
|
+
lastPicked.value?.id === id
|
|
155
|
+
? lastPicked.value
|
|
156
|
+
: fetchedSelected.value?.id === id
|
|
157
|
+
? fetchedSelected.value
|
|
158
|
+
: undefined
|
|
159
|
+
return pinned ? [pinned, ...loadedGroups.value] : loadedGroups.value
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const hasMore = computed(
|
|
163
|
+
() =>
|
|
164
|
+
!isPlaceholderData.value &&
|
|
165
|
+
envelope.value != null &&
|
|
166
|
+
envelope.value.meta.current_page < envelope.value.meta.last_page,
|
|
167
|
+
)
|
|
168
|
+
const isLoadingFirstPage = computed(() => page.value === 1 && loading.value)
|
|
169
|
+
const isLoadingMore = computed(() => page.value > 1 && loading.value)
|
|
170
|
+
|
|
171
|
+
const hideIneligible = ref(false)
|
|
172
|
+
|
|
173
|
+
const rule = computed(() => ELIGIBILITY_RULES[props.eligibility])
|
|
174
|
+
const hideFilterLabel = computed(() => props.hideFilterLabel ?? t(rule.value.hideFilterKey))
|
|
48
175
|
|
|
49
176
|
const items = computed(() => {
|
|
50
|
-
if (!messageGroups.value) return []
|
|
51
177
|
return messageGroups.value.reduce<
|
|
52
178
|
Array<{
|
|
53
179
|
label: string
|
|
@@ -58,20 +184,18 @@ const items = computed(() => {
|
|
|
58
184
|
}>
|
|
59
185
|
>((acc, group) => {
|
|
60
186
|
const externalReason = props.disabledGroupReasons?.[group.id]
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
187
|
+
const eligible = rule.value.isEligible(group.messages)
|
|
188
|
+
const disabled = !eligible || externalReason != null
|
|
189
|
+
if (hideIneligible.value && disabled) return acc
|
|
190
|
+
// External reason wins over the mode's own tooltip when both apply.
|
|
191
|
+
let tooltip: string | undefined
|
|
192
|
+
if (externalReason != null) tooltip = externalReason
|
|
193
|
+
else if (!eligible) tooltip = t(rule.value.tooltipKey)
|
|
65
194
|
acc.push({
|
|
66
195
|
label: group.name,
|
|
67
196
|
value: group.id,
|
|
68
197
|
disabled,
|
|
69
|
-
|
|
70
|
-
tooltip: externallyUsed
|
|
71
|
-
? externalReason
|
|
72
|
-
: !active
|
|
73
|
-
? t('common.messageGroupSelect.inactive_tooltip')
|
|
74
|
-
: undefined,
|
|
198
|
+
tooltip,
|
|
75
199
|
chip: { color: disabled ? 'neutral' : 'success' },
|
|
76
200
|
})
|
|
77
201
|
return acc
|
|
@@ -80,18 +204,21 @@ const items = computed(() => {
|
|
|
80
204
|
|
|
81
205
|
function handleUpdate(value: string) {
|
|
82
206
|
modelValue.value = value
|
|
83
|
-
const group = messageGroups.value
|
|
207
|
+
const group = messageGroups.value.find((g) => g.id === value)
|
|
208
|
+
lastPicked.value = group
|
|
84
209
|
emit('select', { id: value, name: group?.name ?? '' })
|
|
85
210
|
}
|
|
86
211
|
</script>
|
|
87
212
|
|
|
88
213
|
<template>
|
|
89
214
|
<USelectMenu
|
|
215
|
+
v-model:search-term="searchTerm"
|
|
90
216
|
:model-value="modelValue"
|
|
91
217
|
:items="items"
|
|
92
218
|
value-key="value"
|
|
93
|
-
:loading="
|
|
219
|
+
:loading="isLoadingFirstPage"
|
|
94
220
|
searchable
|
|
221
|
+
ignore-filter
|
|
95
222
|
:placeholder="placeholder"
|
|
96
223
|
:search-input="{ placeholder: searchPlaceholder }"
|
|
97
224
|
:data-testid="messageGroupSelectTestIds.root"
|
|
@@ -106,10 +233,21 @@ function handleUpdate(value: string) {
|
|
|
106
233
|
|
|
107
234
|
<template #content-top>
|
|
108
235
|
<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
|
-
|
|
236
|
+
<USwitch v-model="hideIneligible" size="xs" />
|
|
237
|
+
<span class="text-xs text-muted">{{ hideFilterLabel }}</span>
|
|
238
|
+
</div>
|
|
239
|
+
</template>
|
|
240
|
+
|
|
241
|
+
<template v-if="hasMore || isLoadingMore" #content-bottom>
|
|
242
|
+
<div class="flex justify-center px-2 py-1.5 border-t border-default">
|
|
243
|
+
<UButton
|
|
244
|
+
variant="link"
|
|
245
|
+
size="xs"
|
|
246
|
+
:loading="isLoadingMore"
|
|
247
|
+
:label="t('common.messageGroupSelect.load_more')"
|
|
248
|
+
:data-testid="messageGroupSelectTestIds.loadMore"
|
|
249
|
+
@click="page++"
|
|
250
|
+
/>
|
|
113
251
|
</div>
|
|
114
252
|
</template>
|
|
115
253
|
</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) -->
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Holds its content back until there is an Organization to work on, so no
|
|
4
|
+
* resource request leaves before it is resolved (ADR 0014).
|
|
5
|
+
*/
|
|
6
|
+
const organization = useEffectiveOrganizationStore()
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
|
|
9
|
+
const failure = computed(() =>
|
|
10
|
+
organization.status === 'none'
|
|
11
|
+
? {
|
|
12
|
+
title: t('common.organization.none_title'),
|
|
13
|
+
description: t('common.organization.none_description'),
|
|
14
|
+
}
|
|
15
|
+
: {
|
|
16
|
+
title: t('common.organization.lookup_failed_title'),
|
|
17
|
+
description: t('common.organization.lookup_failed_description'),
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<slot v-if="organization.status === 'resolved'" />
|
|
24
|
+
|
|
25
|
+
<div
|
|
26
|
+
v-else-if="organization.status === 'resolving'"
|
|
27
|
+
class="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-muted"
|
|
28
|
+
role="status"
|
|
29
|
+
>
|
|
30
|
+
<UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8" />
|
|
31
|
+
<span class="text-sm">{{ t('common.organization.resolving') }}</span>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div v-else class="p-4">
|
|
35
|
+
<UAlert
|
|
36
|
+
color="error"
|
|
37
|
+
variant="subtle"
|
|
38
|
+
icon="i-lucide-alert-triangle"
|
|
39
|
+
:title="failure.title"
|
|
40
|
+
:description="failure.description"
|
|
41
|
+
:actions="[
|
|
42
|
+
{
|
|
43
|
+
label: t('common.organization.retry'),
|
|
44
|
+
color: 'error',
|
|
45
|
+
variant: 'outline',
|
|
46
|
+
onClick: () => void organization.retry(),
|
|
47
|
+
},
|
|
48
|
+
]"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Organization selector of the list pages (Message Groups, Brands, images):
|
|
4
|
+
* filters what is listed and decides where new resources are created. Renders
|
|
5
|
+
* nothing when the Host supplied the Organization or the user reaches only one.
|
|
6
|
+
*/
|
|
7
|
+
const organization = useEffectiveOrganizationStore()
|
|
8
|
+
const { t } = useI18n()
|
|
9
|
+
|
|
10
|
+
const items = computed(() =>
|
|
11
|
+
organization.reachableOrganizations.map((o) => ({ label: o.name, value: o.id })),
|
|
12
|
+
)
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<USelectMenu
|
|
17
|
+
v-if="organization.isSelectable"
|
|
18
|
+
:model-value="organization.resolvedOrganizationId ?? undefined"
|
|
19
|
+
:items="items"
|
|
20
|
+
value-key="value"
|
|
21
|
+
icon="ph:buildings"
|
|
22
|
+
:search-input="{ placeholder: t('common.organization.search_placeholder') }"
|
|
23
|
+
:aria-label="t('common.organization.label')"
|
|
24
|
+
class="w-56"
|
|
25
|
+
@update:model-value="organization.select($event)"
|
|
26
|
+
/>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Tells the user that the Effective Organization has no WABA: its WhatsApp
|
|
4
|
+
* messages can be drafted but not submitted to Meta. Follows the Organization,
|
|
5
|
+
* so it comes and goes with the selector and with the resource on screen.
|
|
6
|
+
*/
|
|
7
|
+
const { status } = useWhatsappConfiguration()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<UAlert
|
|
12
|
+
v-if="status === 'not_configured'"
|
|
13
|
+
color="warning"
|
|
14
|
+
variant="subtle"
|
|
15
|
+
icon="i-lucide-message-circle-off"
|
|
16
|
+
:title="$t('common.whatsapp_not_configured_title')"
|
|
17
|
+
:description="$t('common.whatsapp_not_configured_description')"
|
|
18
|
+
class="rounded-none"
|
|
19
|
+
/>
|
|
20
|
+
</template>
|
|
@@ -39,6 +39,10 @@ const { data: messageGroupData, isPending: isFetching } =
|
|
|
39
39
|
? useMessageGroupQuery(props.messageGroupId)
|
|
40
40
|
: { data: ref(null), isPending: ref(false) }
|
|
41
41
|
|
|
42
|
+
// The editor works on the Organization of the Message Group it loaded, not on
|
|
43
|
+
// the selector's. Claimed before the watcher below loads dependent data.
|
|
44
|
+
useResourceOrganization(() => messageGroupData.value?.organization_id)
|
|
45
|
+
|
|
42
46
|
// Change tracking (edit mode only)
|
|
43
47
|
const messageGroupIdRef = computed(() => store.messageGroupId ?? '')
|
|
44
48
|
const changeTracking = props.mode === 'edit' ? useChangeTrackingEmail(messageGroupIdRef) : null
|
|
@@ -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,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { OrganizationWaba } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Asks which WABA a Message Group is submitted to, at its first submission,
|
|
6
|
+
* when its Organization has several (ADR 0015). Opened through `useOverlay`:
|
|
7
|
+
* closes with the chosen WABA id, or with nothing when the user gives up.
|
|
8
|
+
*/
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
wabas: OrganizationWaba[]
|
|
11
|
+
/** The Host's WABA, already checked to be one of `wabas`. */
|
|
12
|
+
preselected?: string
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{ close: [wabaId?: string] }>()
|
|
16
|
+
|
|
17
|
+
const selected = ref<string | undefined>(props.preselected)
|
|
18
|
+
|
|
19
|
+
const items = computed(() =>
|
|
20
|
+
props.wabas.map((waba) => ({
|
|
21
|
+
value: waba.id,
|
|
22
|
+
label: waba.name || waba.id,
|
|
23
|
+
numbers: waba.phone_numbers.join(', '),
|
|
24
|
+
// Not `id`: the radio group gives each item one of its own.
|
|
25
|
+
wabaId: waba.id,
|
|
26
|
+
})),
|
|
27
|
+
)
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<UModal
|
|
32
|
+
:title="$t('dialogs.select_waba.title')"
|
|
33
|
+
:description="$t('dialogs.select_waba.description')"
|
|
34
|
+
close-icon="ph:x"
|
|
35
|
+
:close="{ onClick: () => emit('close') }"
|
|
36
|
+
:dismissible="false"
|
|
37
|
+
data-testid="waba-select-modal"
|
|
38
|
+
>
|
|
39
|
+
<template #body>
|
|
40
|
+
<URadioGroup
|
|
41
|
+
v-model="selected"
|
|
42
|
+
:items="items"
|
|
43
|
+
variant="table"
|
|
44
|
+
class="w-full"
|
|
45
|
+
:ui="{ item: 'w-full' }"
|
|
46
|
+
data-testid="waba-select-options"
|
|
47
|
+
>
|
|
48
|
+
<template #description="{ item }">
|
|
49
|
+
<span class="flex flex-col">
|
|
50
|
+
<span>{{ item.numbers || $t('dialogs.select_waba.no_numbers') }}</span>
|
|
51
|
+
<span class="text-xs text-dimmed font-mono">{{ item.wabaId }}</span>
|
|
52
|
+
</span>
|
|
53
|
+
</template>
|
|
54
|
+
</URadioGroup>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<template #footer>
|
|
58
|
+
<div class="flex gap-2 justify-end w-full">
|
|
59
|
+
<UButton
|
|
60
|
+
variant="outline"
|
|
61
|
+
color="neutral"
|
|
62
|
+
:label="$t('common.actions.cancel')"
|
|
63
|
+
data-testid="waba-select-cancel"
|
|
64
|
+
@click="emit('close')"
|
|
65
|
+
/>
|
|
66
|
+
<UButton
|
|
67
|
+
:label="$t('dialogs.select_waba.confirm')"
|
|
68
|
+
:disabled="!selected"
|
|
69
|
+
data-testid="waba-select-confirm"
|
|
70
|
+
@click="emit('close', selected)"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
</UModal>
|
|
75
|
+
</template>
|