@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
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import {
|
|
3
3
|
MESSAGE_GROUP_CATEGORIES,
|
|
4
|
+
MESSAGE_GROUP_LIST_VISIBILITY,
|
|
5
|
+
MESSAGE_GROUP_PRODUCTS,
|
|
4
6
|
MESSAGE_STATUS,
|
|
7
|
+
type MessageGroupListSort,
|
|
5
8
|
type MessageGroupWithMessageSummary,
|
|
9
|
+
type PaginationMeta,
|
|
6
10
|
type RenderType,
|
|
7
11
|
} from '@dev.smartpricing/message-composer-utils/types'
|
|
8
12
|
import StatusCell from '@/components/TemplateList/StatusCell.vue'
|
|
9
13
|
import { useI18n } from 'vue-i18n'
|
|
10
|
-
import { ref, watch } from 'vue'
|
|
14
|
+
import { computed, ref, watch } from 'vue'
|
|
11
15
|
import { formatDate } from '@dev.smartpricing/message-composer-utils/utils'
|
|
12
16
|
import type { TableColumn } from '#ui/types'
|
|
13
17
|
import type { TrackingEventMap } from '@/types/tracking'
|
|
18
|
+
import {
|
|
19
|
+
ALL_FILTER,
|
|
20
|
+
type MessageGroupListFilters,
|
|
21
|
+
type MessageGroupListSorting,
|
|
22
|
+
} from '@/composables/useMessageGroupList'
|
|
23
|
+
|
|
24
|
+
const props = defineProps<{
|
|
25
|
+
messageGroups: MessageGroupWithMessageSummary[]
|
|
26
|
+
/** Envelope meta of the current page; undefined until the first page arrives. */
|
|
27
|
+
meta?: PaginationMeta
|
|
28
|
+
loading: boolean
|
|
29
|
+
renderType: RenderType
|
|
30
|
+
filters: MessageGroupListFilters
|
|
31
|
+
sorting: MessageGroupListSorting
|
|
32
|
+
page: number
|
|
33
|
+
}>()
|
|
14
34
|
|
|
15
35
|
const emit = defineEmits<{
|
|
16
36
|
edit: [MessageGroupWithMessageSummary]
|
|
@@ -18,6 +38,9 @@ const emit = defineEmits<{
|
|
|
18
38
|
duplicate: [MessageGroupWithMessageSummary]
|
|
19
39
|
publish: [MessageGroupWithMessageSummary]
|
|
20
40
|
unpublish: [MessageGroupWithMessageSummary]
|
|
41
|
+
'update:filters': [Partial<MessageGroupListFilters>]
|
|
42
|
+
'update:sorting': [MessageGroupListSorting]
|
|
43
|
+
'update:page': [number]
|
|
21
44
|
}>()
|
|
22
45
|
|
|
23
46
|
const appContext = useAppContextStore()
|
|
@@ -86,49 +109,40 @@ function rowActions(row: MessageGroupWithMessageSummary) {
|
|
|
86
109
|
]
|
|
87
110
|
}
|
|
88
111
|
|
|
112
|
+
// Column ids of sortable columns are the server's `sort` values (ADR 0012);
|
|
113
|
+
// preview and status are aggregates and stay unsortable.
|
|
89
114
|
const columns: TableColumn<MessageGroupWithMessageSummary>[] = [
|
|
90
115
|
{
|
|
91
|
-
id: 'name',
|
|
116
|
+
id: 'name' satisfies MessageGroupListSort,
|
|
92
117
|
accessorKey: 'name',
|
|
93
118
|
header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.name')),
|
|
94
119
|
},
|
|
95
120
|
{
|
|
96
|
-
id: 'category',
|
|
121
|
+
id: 'category' satisfies MessageGroupListSort,
|
|
97
122
|
accessorKey: 'category',
|
|
98
123
|
header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.category')),
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'product',
|
|
127
|
+
accessorKey: 'product',
|
|
128
|
+
enableSorting: false,
|
|
129
|
+
header: () => t('pages.message_library.headers.product'),
|
|
103
130
|
},
|
|
104
131
|
{
|
|
105
132
|
id: 'preview',
|
|
106
133
|
accessorFn: (row) => getLocalizedMessage(row)?.preview,
|
|
107
|
-
|
|
134
|
+
enableSorting: false,
|
|
135
|
+
header: () => t('pages.message_library.headers.preview'),
|
|
108
136
|
},
|
|
109
137
|
{
|
|
110
138
|
id: 'status',
|
|
111
139
|
accessorFn: (row) => row.messages.map((m) => m.status).join(', '),
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (filterValue === 'all') return true
|
|
115
|
-
return row.original.messages.some((m) => m.status === filterValue)
|
|
116
|
-
},
|
|
140
|
+
enableSorting: false,
|
|
141
|
+
header: () => t('pages.message_library.headers.status'),
|
|
117
142
|
},
|
|
118
|
-
// {
|
|
119
|
-
// id: 'tags',
|
|
120
|
-
// accessorFn: (row) => row.tags?.join(', ') || '',
|
|
121
|
-
// header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.tags')),
|
|
122
|
-
// filterFn: (row, _columnId, filterValue: string[]) => {
|
|
123
|
-
// if (!filterValue || filterValue.length === 0) return true
|
|
124
|
-
// const rowTags = row.original.tags || []
|
|
125
|
-
// return filterValue.some((tag) => rowTags.includes(tag))
|
|
126
|
-
// },
|
|
127
|
-
// },
|
|
128
143
|
{
|
|
129
|
-
id: 'updated_at',
|
|
144
|
+
id: 'updated_at' satisfies MessageGroupListSort,
|
|
130
145
|
accessorFn: (row) => formatDate(row.updated_at),
|
|
131
|
-
// header: () => t('pages.message_library.headers.updated_at'),
|
|
132
146
|
header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.updated_at')),
|
|
133
147
|
},
|
|
134
148
|
{
|
|
@@ -144,77 +158,70 @@ const columns: TableColumn<MessageGroupWithMessageSummary>[] = [
|
|
|
144
158
|
},
|
|
145
159
|
]
|
|
146
160
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
},
|
|
152
|
-
])
|
|
161
|
+
// The table's sorting model mirrors the server sort; TanStack never sorts locally (manualSorting).
|
|
162
|
+
function toTableSorting(sorting: MessageGroupListSorting) {
|
|
163
|
+
return [{ id: sorting.sort, desc: sorting.order === 'desc' }]
|
|
164
|
+
}
|
|
153
165
|
|
|
154
|
-
const
|
|
155
|
-
messageGroups: MessageGroupWithMessageSummary[]
|
|
156
|
-
loading: boolean
|
|
157
|
-
renderType: RenderType
|
|
158
|
-
}>()
|
|
166
|
+
const sorting = ref(toTableSorting(props.sorting))
|
|
159
167
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
watch(
|
|
169
|
+
() => props.sorting,
|
|
170
|
+
(value) => {
|
|
171
|
+
sorting.value = toTableSorting(value)
|
|
172
|
+
},
|
|
173
|
+
)
|
|
164
174
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
watch(sorting, (val) => {
|
|
176
|
+
const [first] = val
|
|
177
|
+
if (!first) return
|
|
178
|
+
const next: MessageGroupListSorting = {
|
|
179
|
+
sort: first.id as MessageGroupListSort,
|
|
180
|
+
order: first.desc ? 'desc' : 'asc',
|
|
181
|
+
}
|
|
182
|
+
if (next.sort === props.sorting.sort && next.order === props.sorting.order) return
|
|
183
|
+
emit('update:sorting', next)
|
|
184
|
+
trackEvent('table_sorting', {
|
|
185
|
+
messages_kind: messagesKind.value,
|
|
186
|
+
sort_column: next.sort,
|
|
187
|
+
})
|
|
176
188
|
})
|
|
177
189
|
|
|
178
|
-
|
|
190
|
+
// Select items: the "all" entry first, then one translated entry per enum value.
|
|
191
|
+
function toSelectItems<T extends string>(
|
|
192
|
+
values: readonly T[],
|
|
193
|
+
keyPrefix: string,
|
|
194
|
+
allLabel: string,
|
|
195
|
+
) {
|
|
179
196
|
return [
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
value: 'all',
|
|
183
|
-
},
|
|
184
|
-
|
|
185
|
-
...MESSAGE_STATUS.map((status) => ({
|
|
186
|
-
label: t(`status.${status}`),
|
|
187
|
-
value: status,
|
|
188
|
-
})),
|
|
197
|
+
{ label: allLabel, value: ALL_FILTER },
|
|
198
|
+
...values.map((value) => ({ label: t(`${keyPrefix}.${value}`), value })),
|
|
189
199
|
]
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// const tagItems = computed(() => {
|
|
193
|
-
// const allTags = new Set<string>()
|
|
194
|
-
// messageGroups.forEach((mg) => mg.tags?.forEach((tag) => allTags.add(tag)))
|
|
195
|
-
// return [...allTags].sort()
|
|
196
|
-
// })
|
|
197
|
-
|
|
198
|
-
// const selectedTags = ref<string[]>([])
|
|
200
|
+
}
|
|
199
201
|
|
|
200
|
-
const
|
|
202
|
+
const categoryItems = computed(() =>
|
|
203
|
+
toSelectItems(MESSAGE_GROUP_CATEGORIES, 'categories', t('common.all')),
|
|
204
|
+
)
|
|
205
|
+
const statusItems = computed(() => toSelectItems(MESSAGE_STATUS, 'status', t('common.all')))
|
|
206
|
+
const productItems = computed(() =>
|
|
207
|
+
toSelectItems(MESSAGE_GROUP_PRODUCTS, 'products', t('pages.message_library.all_products')),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const visibilityItems = computed(() =>
|
|
211
|
+
MESSAGE_GROUP_LIST_VISIBILITY.map((visibility) => ({
|
|
212
|
+
label: t(`pages.message_library.visibility.${visibility}`),
|
|
213
|
+
value: visibility,
|
|
214
|
+
})),
|
|
215
|
+
)
|
|
201
216
|
|
|
202
217
|
const messagesKind = computed(() => (props.renderType === 'email_v1' ? 'email' : 'whatsapp'))
|
|
203
218
|
|
|
204
|
-
watch(sorting, (val) => {
|
|
205
|
-
if (val[0]) {
|
|
206
|
-
trackEvent('table_sorting', {
|
|
207
|
-
messages_kind: messagesKind.value,
|
|
208
|
-
sort_column: val[0].id,
|
|
209
|
-
})
|
|
210
|
-
}
|
|
211
|
-
})
|
|
212
|
-
|
|
213
219
|
function trackFilters(overrides: Partial<TrackingEventMap['change_filters']> = {}) {
|
|
214
220
|
trackEvent('change_filters', {
|
|
215
|
-
search_term:
|
|
216
|
-
category:
|
|
217
|
-
status:
|
|
221
|
+
search_term: props.filters.search,
|
|
222
|
+
category: props.filters.category,
|
|
223
|
+
status: props.filters.status,
|
|
224
|
+
product: props.filters.product,
|
|
218
225
|
messages_kind: messagesKind.value,
|
|
219
226
|
...overrides,
|
|
220
227
|
})
|
|
@@ -225,19 +232,24 @@ const trackSearchFilter = useDebounceFn((value: string) => {
|
|
|
225
232
|
}, 500)
|
|
226
233
|
|
|
227
234
|
function onSearchInput(value: string) {
|
|
228
|
-
|
|
235
|
+
emit('update:filters', { search: value })
|
|
229
236
|
trackSearchFilter(value)
|
|
230
237
|
}
|
|
231
238
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
239
|
+
// USelect emits the option value as a plain string; the items are built from the typed enums.
|
|
240
|
+
function onFilter(key: Exclude<keyof MessageGroupListFilters, 'search'>, value: string) {
|
|
241
|
+
emit('update:filters', { [key]: value } as Partial<MessageGroupListFilters>)
|
|
242
|
+
if (key !== 'visibility') trackFilters({ [key]: value })
|
|
235
243
|
}
|
|
236
244
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
245
|
+
const paginationSummary = computed(() => {
|
|
246
|
+
if (!props.meta || props.meta.from === null || props.meta.to === null) return ''
|
|
247
|
+
return t('pages.message_library.pagination_summary', {
|
|
248
|
+
from: props.meta.from,
|
|
249
|
+
to: props.meta.to,
|
|
250
|
+
total: props.meta.total,
|
|
251
|
+
})
|
|
252
|
+
})
|
|
241
253
|
</script>
|
|
242
254
|
|
|
243
255
|
<template>
|
|
@@ -247,38 +259,46 @@ function onStatusFilter(value: string) {
|
|
|
247
259
|
>
|
|
248
260
|
<div class="flex gap-2">
|
|
249
261
|
<UInput
|
|
250
|
-
:model-value="
|
|
262
|
+
:model-value="filters.search"
|
|
251
263
|
class="max-w-xs"
|
|
252
264
|
leading-icon="ph:magnifying-glass-bold"
|
|
253
265
|
:placeholder="t('pages.message_library.search_placeholder')"
|
|
254
266
|
:data-testid="templateListTableTestIds.searchInput"
|
|
255
|
-
@update:model-value="onSearchInput($event)"
|
|
267
|
+
@update:model-value="onSearchInput(String($event))"
|
|
256
268
|
/>
|
|
257
269
|
<USelect
|
|
258
|
-
:model-value="
|
|
270
|
+
:model-value="filters.category"
|
|
259
271
|
class="max-w-48"
|
|
260
272
|
:placeholder="t('pages.message_library.category_placeholder')"
|
|
261
273
|
:items="categoryItems"
|
|
262
274
|
:data-testid="templateListTableTestIds.categorySelect"
|
|
263
|
-
@update:model-value="
|
|
275
|
+
@update:model-value="onFilter('category', $event)"
|
|
264
276
|
/>
|
|
265
277
|
<USelect
|
|
266
|
-
:model-value="
|
|
278
|
+
:model-value="filters.status"
|
|
267
279
|
class="max-w-48"
|
|
268
280
|
:placeholder="t('pages.message_library.status_placeholder')"
|
|
269
281
|
:items="statusItems"
|
|
270
282
|
:data-testid="templateListTableTestIds.statusSelect"
|
|
271
|
-
@update:model-value="
|
|
283
|
+
@update:model-value="onFilter('status', $event)"
|
|
284
|
+
/>
|
|
285
|
+
<USelect
|
|
286
|
+
:model-value="filters.product"
|
|
287
|
+
class="max-w-48"
|
|
288
|
+
:placeholder="t('pages.message_library.product_placeholder')"
|
|
289
|
+
:items="productItems"
|
|
290
|
+
:data-testid="templateListTableTestIds.productSelect"
|
|
291
|
+
@update:model-value="onFilter('product', $event)"
|
|
292
|
+
/>
|
|
293
|
+
<USelect
|
|
294
|
+
v-if="appContext.isAdmin"
|
|
295
|
+
:model-value="filters.visibility"
|
|
296
|
+
class="max-w-48"
|
|
297
|
+
:placeholder="t('pages.message_library.visibility_placeholder')"
|
|
298
|
+
:items="visibilityItems"
|
|
299
|
+
:data-testid="templateListTableTestIds.visibilitySelect"
|
|
300
|
+
@update:model-value="onFilter('visibility', $event)"
|
|
272
301
|
/>
|
|
273
|
-
<!-- <USelectMenu
|
|
274
|
-
v-if="tagItems.length > 0"
|
|
275
|
-
v-model="selectedTags"
|
|
276
|
-
:items="tagItems"
|
|
277
|
-
multiple
|
|
278
|
-
class="max-w-48"
|
|
279
|
-
:placeholder="t('pages.message_library.tags_placeholder')"
|
|
280
|
-
@update:model-value="table?.tableApi?.getColumn('tags')?.setFilterValue($event)"
|
|
281
|
-
/> -->
|
|
282
302
|
</div>
|
|
283
303
|
|
|
284
304
|
<UCard
|
|
@@ -295,10 +315,10 @@ function onStatusFilter(value: string) {
|
|
|
295
315
|
tr: 'hover:bg-muted',
|
|
296
316
|
root: 'overflow-visible static',
|
|
297
317
|
}"
|
|
298
|
-
:data="
|
|
318
|
+
:data="messageGroups"
|
|
299
319
|
:columns="columns"
|
|
320
|
+
:sorting-options="{ manualSorting: true, enableSortingRemoval: false }"
|
|
300
321
|
sticky
|
|
301
|
-
ref="table"
|
|
302
322
|
v-model:sorting="sorting"
|
|
303
323
|
:data-testid="templateListTableTestIds.table"
|
|
304
324
|
>
|
|
@@ -329,6 +349,16 @@ function onStatusFilter(value: string) {
|
|
|
329
349
|
/>
|
|
330
350
|
</template>
|
|
331
351
|
|
|
352
|
+
<template #product-cell="{ row }">
|
|
353
|
+
<UBadge
|
|
354
|
+
:label="t(`products.${row.original.product}`)"
|
|
355
|
+
size="md"
|
|
356
|
+
color="neutral"
|
|
357
|
+
variant="outline"
|
|
358
|
+
:data-testid="`${templateListTableTestIds.rowProductBadge}-${row.original.id}`"
|
|
359
|
+
/>
|
|
360
|
+
</template>
|
|
361
|
+
|
|
332
362
|
<template #status-cell="{ row }">
|
|
333
363
|
<StatusCell :items="row.original.messages" />
|
|
334
364
|
</template>
|
|
@@ -349,6 +379,24 @@ function onStatusFilter(value: string) {
|
|
|
349
379
|
/>
|
|
350
380
|
</template>
|
|
351
381
|
</UTable>
|
|
382
|
+
|
|
383
|
+
<template v-if="meta" #footer>
|
|
384
|
+
<div class="flex items-center justify-between gap-4">
|
|
385
|
+
<span
|
|
386
|
+
class="text-sm text-muted"
|
|
387
|
+
:data-testid="templateListTableTestIds.paginationSummary"
|
|
388
|
+
>
|
|
389
|
+
{{ paginationSummary }}
|
|
390
|
+
</span>
|
|
391
|
+
<UPagination
|
|
392
|
+
:page="page"
|
|
393
|
+
:total="meta.total"
|
|
394
|
+
:items-per-page="meta.per_page"
|
|
395
|
+
:data-testid="templateListTableTestIds.pagination"
|
|
396
|
+
@update:page="emit('update:page', $event)"
|
|
397
|
+
/>
|
|
398
|
+
</div>
|
|
399
|
+
</template>
|
|
352
400
|
</UCard>
|
|
353
401
|
</div>
|
|
354
402
|
</template>
|
|
@@ -20,6 +20,13 @@ defineOptions({
|
|
|
20
20
|
const props = defineProps<{
|
|
21
21
|
message: PostEntity<WhatsappMessage>
|
|
22
22
|
messageGroupId: string
|
|
23
|
+
/**
|
|
24
|
+
* Parameter values that override the resolved (organization-level) ones,
|
|
25
|
+
* e.g. what an operator typed in a Message Send Flow. Non-empty values win,
|
|
26
|
+
* keys not in the map keep the resolved value, an empty string shows the raw
|
|
27
|
+
* `{{key}}` placeholder (as an empty resolved value always did).
|
|
28
|
+
*/
|
|
29
|
+
parameters?: Record<string, string>
|
|
23
30
|
}>()
|
|
24
31
|
|
|
25
32
|
const { data: resolvedParams } = useResolvedParametersQuery(
|
|
@@ -28,13 +35,14 @@ const { data: resolvedParams } = useResolvedParametersQuery(
|
|
|
28
35
|
)
|
|
29
36
|
|
|
30
37
|
function replaceMentionsWithValues(html: string): string {
|
|
31
|
-
const dynamicData = resolvedParams.value?.parameters
|
|
32
|
-
if (!dynamicData) return html
|
|
38
|
+
const dynamicData = { ...resolvedParams.value?.parameters, ...props.parameters }
|
|
33
39
|
|
|
34
40
|
let replacedHtml = html
|
|
35
41
|
for (const [key, value] of Object.entries(dynamicData)) {
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
// Literal match and a replacer function: keys and operator-typed values
|
|
43
|
+
// must never be read as regex or `$` replacement patterns.
|
|
44
|
+
const placeholder = `{{${key}}}`
|
|
45
|
+
replacedHtml = replacedHtml.replaceAll(placeholder, () => value || placeholder)
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
return replacedHtml
|
|
@@ -9,6 +9,8 @@ defineOptions({
|
|
|
9
9
|
defineProps<{
|
|
10
10
|
message: PostEntity<WhatsappMessage>
|
|
11
11
|
messageGroupId: string
|
|
12
|
+
/** Forwarded to `WhatsappPreview`: operator-typed values overriding the resolved ones. */
|
|
13
|
+
parameters?: Record<string, string>
|
|
12
14
|
}>()
|
|
13
15
|
</script>
|
|
14
16
|
|
|
@@ -19,6 +21,10 @@ defineProps<{
|
|
|
19
21
|
<h3 class="label-lg">{{ $t('common.preview') }}</h3>
|
|
20
22
|
</div>
|
|
21
23
|
|
|
22
|
-
<WhatsappPreview
|
|
24
|
+
<WhatsappPreview
|
|
25
|
+
:message="message"
|
|
26
|
+
:message-group-id="messageGroupId"
|
|
27
|
+
:parameters="parameters"
|
|
28
|
+
/>
|
|
23
29
|
</div>
|
|
24
30
|
</template>
|
|
@@ -9,18 +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
|
-
|
|
16
|
-
|
|
13
|
+
/** The Product the host embeds the composer in (ADR 0011). Required. */
|
|
14
|
+
product: Ref<AppContextConfig['product']>
|
|
17
15
|
channels?: Ref<AppContextConfig['channels']>
|
|
18
16
|
languages?: Ref<string[]>
|
|
19
17
|
mainLanguage?: Ref<string>
|
|
20
|
-
dinamicValues?: Ref<Array<{ value: string; label: string }>>
|
|
21
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
|
+
*/
|
|
22
24
|
metaWabaId?: Ref<string>
|
|
23
|
-
metaAccessToken?: Ref<string>
|
|
24
25
|
isAdmin?: Ref<boolean>
|
|
25
26
|
organizationData?: Ref<OrganizationData>
|
|
26
27
|
}
|
|
@@ -33,7 +34,8 @@ export function defineComposerContext(context: ComposerContext): void {
|
|
|
33
34
|
useNuxtApp().vueApp.provide(COMPOSER_CONTEXT_KEY, context)
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// Server render never reads the context; the store reads every field defensively.
|
|
38
|
+
const SERVER_FALLBACK = {} as ComposerContext
|
|
37
39
|
|
|
38
40
|
export function useComposerContext(): ComposerContext {
|
|
39
41
|
if (import.meta.server) return SERVER_FALLBACK
|
|
@@ -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
|
+
}
|