@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.
Files changed (61) hide show
  1. package/README.md +228 -46
  2. package/app/api/brands.ts +20 -13
  3. package/app/api/client.ts +15 -6
  4. package/app/api/compilation.ts +13 -3
  5. package/app/api/dynamicValues.ts +6 -2
  6. package/app/api/media.ts +6 -3
  7. package/app/api/messageGroups.ts +29 -14
  8. package/app/api/meta.ts +26 -4
  9. package/app/api/organization.ts +10 -1
  10. package/app/api/tracking.ts +2 -2
  11. package/app/components/Common/AppContextErrors.vue +30 -0
  12. package/app/components/Common/HtmlPreviewIframe.vue +17 -3
  13. package/app/components/Common/MessageGroupSelect.vue +164 -26
  14. package/app/components/Common/MessagePreviewSlideover.vue +3 -0
  15. package/app/components/Common/OrganizationGate.vue +51 -0
  16. package/app/components/Common/OrganizationSelect.vue +27 -0
  17. package/app/components/Common/WhatsappNotConfigured.vue +20 -0
  18. package/app/components/Composer/EmailComposer.vue +4 -0
  19. package/app/components/Composer/MessageLibrary.vue +17 -7
  20. package/app/components/Composer/WabaSelectModal.vue +75 -0
  21. package/app/components/Composer/WhatsappComposer.vue +78 -3
  22. package/app/components/MessageSendFlow/EmailPreview.vue +81 -0
  23. package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
  24. package/app/components/MessageSendFlow/MessageSendFlow.vue +265 -0
  25. package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
  26. package/app/components/TemplateList/BaseTable.vue +156 -108
  27. package/app/components/Whatsapp/Preview.vue +12 -4
  28. package/app/components/Whatsapp/PreviewPanel.vue +7 -1
  29. package/app/composables/composerContext.ts +9 -7
  30. package/app/composables/useEffectiveOrganization.ts +34 -0
  31. package/app/composables/useMessageGroupList.ts +186 -0
  32. package/app/composables/useMessageGroupWaba.ts +73 -0
  33. package/app/composables/useMessageSendFlow.ts +284 -0
  34. package/app/composables/usePagedMessageGroups.ts +78 -0
  35. package/app/composables/useTemplateOperations.ts +23 -4
  36. package/app/composables/useTracking.ts +14 -5
  37. package/app/composables/useWhatsappConfiguration.ts +32 -0
  38. package/app/layouts/default.vue +5 -1
  39. package/app/pages/brands.vue +1 -0
  40. package/app/pages/images.vue +4 -0
  41. package/app/pages/index.vue +1 -0
  42. package/app/pages/preview/[id].vue +2 -0
  43. package/app/pages/template/whatsapp/[id].vue +1 -0
  44. package/app/queries/brands.ts +27 -7
  45. package/app/queries/compilation.ts +16 -3
  46. package/app/queries/dynamicValues.ts +19 -2
  47. package/app/queries/media.ts +8 -3
  48. package/app/queries/messageGroups.ts +56 -17
  49. package/app/queries/meta.ts +13 -8
  50. package/app/stores/appContext.ts +18 -48
  51. package/app/stores/effectiveOrganization.ts +128 -0
  52. package/app/stores/emailEditor.ts +5 -1
  53. package/app/stores/metaSubmission.ts +7 -17
  54. package/app/types/tracking.ts +17 -0
  55. package/app/utils/testIds.const.ts +29 -0
  56. package/i18n/locales/de.ts +84 -2
  57. package/i18n/locales/en.ts +80 -2
  58. package/i18n/locales/es.ts +81 -2
  59. package/i18n/locales/fr.ts +81 -2
  60. package/i18n/locales/it.ts +82 -2
  61. package/package.json +2 -2
@@ -0,0 +1,186 @@
1
+ import type { MaybeRefOrGetter } from 'vue'
2
+ import {
3
+ MESSAGE_GROUP_CATEGORIES,
4
+ MESSAGE_GROUP_LIST_SORT,
5
+ MESSAGE_GROUP_LIST_VISIBILITY,
6
+ MESSAGE_GROUP_PRODUCTS,
7
+ MESSAGE_STATUS,
8
+ SORT_ORDER,
9
+ type MessageGroupCategory,
10
+ type MessageGroupListSort,
11
+ type MessageGroupListVisibility,
12
+ type MessageGroupProduct,
13
+ type MessageStatus,
14
+ type RenderType,
15
+ type SortOrder,
16
+ } from '@dev.smartpricing/message-composer-utils/types'
17
+ import { usePagedMessageGroups } from '~/composables/usePagedMessageGroups'
18
+ import { useAppContextStore } from '~/stores/appContext'
19
+
20
+ /** Sentinel for "no filter" in the selects; never sent to the server. */
21
+ export const ALL_FILTER = 'all'
22
+
23
+ export interface MessageGroupListFilters {
24
+ search: string
25
+ category: MessageGroupCategory | typeof ALL_FILTER
26
+ status: MessageStatus | typeof ALL_FILTER
27
+ product: MessageGroupProduct | typeof ALL_FILTER
28
+ /** Only admins may ask for `internal` or `all`; non-admins always request `public`. */
29
+ visibility: MessageGroupListVisibility
30
+ }
31
+
32
+ export interface MessageGroupListSorting {
33
+ sort: MessageGroupListSort
34
+ order: SortOrder
35
+ }
36
+
37
+ export interface MessageGroupListState extends MessageGroupListFilters, MessageGroupListSorting {
38
+ page: number
39
+ perPage: number
40
+ }
41
+
42
+ const STORAGE_KEY = 'message_library_list_state'
43
+ const PER_PAGE = 25
44
+
45
+ function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T {
46
+ return typeof value === 'string' && (values as readonly string[]).includes(value)
47
+ }
48
+
49
+ type StoredState = Partial<Record<keyof MessageGroupListState | 'contextProduct', unknown>>
50
+
51
+ /**
52
+ * Reads the state saved by this composer session. State is scoped to the
53
+ * context Product it was saved under: after a host Product switch the library
54
+ * must open prefiltered on the new Product, not on a stale choice.
55
+ */
56
+ function readStoredState(contextProduct: MessageGroupProduct | undefined): StoredState {
57
+ try {
58
+ const raw = sessionStorage.getItem(STORAGE_KEY)
59
+ const parsed: unknown = raw ? JSON.parse(raw) : null
60
+ if (!parsed || typeof parsed !== 'object') return {}
61
+ const stored = parsed as StoredState
62
+ return stored.contextProduct === contextProduct ? stored : {}
63
+ } catch {
64
+ return {}
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Restores the previous list state of this composer session, field by field:
70
+ * anything missing or no longer valid falls back to the default.
71
+ */
72
+ function restoreState(
73
+ defaults: MessageGroupListState,
74
+ contextProduct: MessageGroupProduct | undefined,
75
+ ): MessageGroupListState {
76
+ const stored = readStoredState(contextProduct)
77
+ const withAll = <T extends string>(values: readonly T[]) => [...values, ALL_FILTER] as const
78
+ return {
79
+ search: typeof stored.search === 'string' ? stored.search : defaults.search,
80
+ category: isOneOf(withAll(MESSAGE_GROUP_CATEGORIES), stored.category)
81
+ ? stored.category
82
+ : defaults.category,
83
+ status: isOneOf(withAll(MESSAGE_STATUS), stored.status) ? stored.status : defaults.status,
84
+ product: isOneOf(withAll(MESSAGE_GROUP_PRODUCTS), stored.product)
85
+ ? stored.product
86
+ : defaults.product,
87
+ visibility: isOneOf(MESSAGE_GROUP_LIST_VISIBILITY, stored.visibility)
88
+ ? stored.visibility
89
+ : defaults.visibility,
90
+ sort: isOneOf(MESSAGE_GROUP_LIST_SORT, stored.sort) ? stored.sort : defaults.sort,
91
+ order: isOneOf(SORT_ORDER, stored.order) ? stored.order : defaults.order,
92
+ page:
93
+ Number.isInteger(stored.page) && (stored.page as number) >= 1 ? (stored.page as number) : 1,
94
+ perPage: defaults.perPage,
95
+ }
96
+ }
97
+
98
+ /** A select value of `all` means "no filter": the param is left out. */
99
+ function unlessAll<T extends string>(value: T | typeof ALL_FILTER): T | undefined {
100
+ return value === ALL_FILTER ? undefined : value
101
+ }
102
+
103
+ function persistState(
104
+ state: MessageGroupListState,
105
+ contextProduct: MessageGroupProduct | undefined,
106
+ ) {
107
+ try {
108
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...state, contextProduct }))
109
+ } catch {
110
+ // Storage unavailable (private mode, quota): the list still works, it just won't be restored.
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Owns the library table's state (filters, sorting, page) as component-scoped
116
+ * reactive state and exposes the server-side paginated query (ADR 0012).
117
+ *
118
+ * - The Product filter starts on the context Product and can be widened to all.
119
+ * - Any filter or sort change lands on page one; search is debounced.
120
+ * - The previous page stays visible while the next one loads.
121
+ * - State survives navigating into a group and back within the session, never the host URL.
122
+ */
123
+ export function useMessageGroupList(renderType: MaybeRefOrGetter<RenderType>) {
124
+ const appContext = useAppContextStore()
125
+
126
+ const defaults: MessageGroupListState = {
127
+ search: '',
128
+ category: ALL_FILTER,
129
+ status: ALL_FILTER,
130
+ product: appContext.product ?? ALL_FILTER,
131
+ // Admins keep seeing internal and public groups by default, as before the
132
+ // server-side list; the select lets them narrow. Non-admins never send this.
133
+ visibility: appContext.isAdmin ? 'all' : 'public',
134
+ sort: 'updated_at',
135
+ order: 'desc',
136
+ page: 1,
137
+ perPage: PER_PAGE,
138
+ }
139
+
140
+ const state = reactive<MessageGroupListState>(restoreState(defaults, appContext.product))
141
+
142
+ const { rows, meta, loading, query } = usePagedMessageGroups({
143
+ search: toRef(state, 'search'),
144
+ page: toRef(state, 'page'),
145
+ perPage: PER_PAGE,
146
+ renderType,
147
+ filters: () => ({
148
+ category: unlessAll(state.category),
149
+ status: unlessAll(state.status),
150
+ product: unlessAll(state.product),
151
+ visibility: appContext.isAdmin ? state.visibility : 'public',
152
+ sort: state.sort,
153
+ order: state.order,
154
+ }),
155
+ })
156
+
157
+ watch(state, (value) => persistState(value, appContext.product), { deep: true })
158
+
159
+ function setFilters(patch: Partial<MessageGroupListFilters>) {
160
+ Object.assign(state, patch)
161
+ // Search resets the page only once the debounced value reaches the query.
162
+ const { search: _search, ...immediate } = patch
163
+ if (Object.keys(immediate).length > 0) state.page = 1
164
+ }
165
+
166
+ function setSorting(sorting: MessageGroupListSorting) {
167
+ state.sort = sorting.sort
168
+ state.order = sorting.order
169
+ state.page = 1
170
+ }
171
+
172
+ function setPage(page: number) {
173
+ state.page = page
174
+ }
175
+
176
+ return {
177
+ state: readonly(state),
178
+ setFilters,
179
+ setSorting,
180
+ setPage,
181
+ rows,
182
+ meta,
183
+ loading,
184
+ refetch: query.refetch,
185
+ }
186
+ }
@@ -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
+ }
@@ -0,0 +1,284 @@
1
+ import { useQuery } from '@pinia/colada'
2
+ import type { MaybeRefOrGetter, Ref } from 'vue'
3
+ import { computed, ref, toValue, watch } from 'vue'
4
+ import type {
5
+ EmailMessage,
6
+ MessageStatus,
7
+ PostEntity,
8
+ RenderType,
9
+ SendableMessage,
10
+ WhatsappMessage,
11
+ } from '@dev.smartpricing/message-composer-utils/types'
12
+ import { compilationApi } from '~/api'
13
+ import { useResolvedParametersQuery } from '~/queries/compilation'
14
+ import { useMessageGroupQuery } from '~/queries/messageGroups'
15
+ import { useTracking } from '~/composables/useTracking'
16
+
17
+ /** Codes a host receives on `error`; the backend ones pass through unchanged. */
18
+ export type MessageSendFlowErrorCode =
19
+ | 'NOT_SENDABLE'
20
+ | 'NOT_OFFERED'
21
+ | 'MISSING_PARAMETERS'
22
+ | 'TEMPLATE_NOT_SUBMITTED'
23
+ | 'TEMPLATE_NOT_APPROVED'
24
+ | 'COMPILE_FAILED'
25
+
26
+ const BACKEND_CODES = new Set<MessageSendFlowErrorCode>([
27
+ 'TEMPLATE_NOT_SUBMITTED',
28
+ 'TEMPLATE_NOT_APPROVED',
29
+ 'MISSING_PARAMETERS',
30
+ ])
31
+
32
+ export interface MessageSendFlowError {
33
+ code: MessageSendFlowErrorCode
34
+ /** The layer's translated text, the same string the inline alert shows. */
35
+ message: string
36
+ /** Missing keys (client-side) or the backend error body. */
37
+ details?: unknown
38
+ }
39
+
40
+ export interface MessageSendFlowEmits {
41
+ compiled: [message: SendableMessage]
42
+ cancel: []
43
+ error: [error: MessageSendFlowError]
44
+ }
45
+
46
+ export interface MessageSendFlowLanguage {
47
+ code: string
48
+ status: MessageStatus
49
+ approved: boolean
50
+ }
51
+
52
+ /** What an operator must fill; `brand_*` keys are baked server-side. */
53
+ export function isOperatorParameter(key: string): boolean {
54
+ return !key.startsWith('brand_')
55
+ }
56
+
57
+ /**
58
+ * Data flow of the Message Send Flow (spec #67): Sendable group → approved
59
+ * language → parameters → one `/compile` on Confirm. The component owns the
60
+ * steps and the buttons; the host owns the container and the send.
61
+ */
62
+ export function useMessageSendFlow(options: {
63
+ groupId: Ref<string | undefined>
64
+ renderType: MaybeRefOrGetter<RenderType>
65
+ preferredLanguage: MaybeRefOrGetter<string | undefined>
66
+ prefill: MaybeRefOrGetter<Record<string, string> | undefined>
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>
72
+ emit: <K extends keyof MessageSendFlowEmits>(event: K, ...args: MessageSendFlowEmits[K]) => void
73
+ }) {
74
+ const { groupId, emit } = options
75
+ const { t } = useI18n()
76
+ const { trackEvent } = useTracking({ organizationId: options.organizationId })
77
+
78
+ // --- Group -------------------------------------------------------------
79
+ const { data: group, isLoading: groupLoading } = useMessageGroupQuery(() => groupId.value ?? '', {
80
+ enabled: () => !!groupId.value,
81
+ })
82
+
83
+ const messages = computed(() =>
84
+ (group.value?.messages ?? []).filter((m) => m.render_type === toValue(options.renderType)),
85
+ )
86
+
87
+ const languages = computed<MessageSendFlowLanguage[]>(() =>
88
+ messages.value.map((m) => ({
89
+ code: m.language_id,
90
+ status: m.status,
91
+ approved: m.status === 'approved',
92
+ })),
93
+ )
94
+
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))
112
+ const notSendable = computed(
113
+ () => !!groupId.value && !!group.value && !groupLoading.value && !sendable.value,
114
+ )
115
+
116
+ // --- Language rule: preferred if approved, else first approved -----------
117
+ const selectedLanguage = ref<string>()
118
+ watch(
119
+ [messages, () => toValue(options.preferredLanguage)],
120
+ ([msgs, preferred]) => {
121
+ const approved = msgs.filter((m) => m.status === 'approved')
122
+ const match = approved.find((m) => m.language_id === preferred) ?? approved[0]
123
+ selectedLanguage.value = match?.language_id
124
+ },
125
+ { immediate: true },
126
+ )
127
+
128
+ const selectedMessage = computed(
129
+ () =>
130
+ messages.value.find((m) => m.language_id === selectedLanguage.value) as
131
+ | PostEntity<WhatsappMessage>
132
+ | PostEntity<EmailMessage>
133
+ | undefined,
134
+ )
135
+
136
+ // --- Parameters ----------------------------------------------------------
137
+ const parametersEnabled = () => !!groupId.value && !!selectedLanguage.value
138
+ const organization = useEffectiveOrganizationStore()
139
+
140
+ const { data: parametersData, isLoading: parametersLoading } = useQuery({
141
+ key: () => [
142
+ 'send-flow',
143
+ 'parameters',
144
+ groupId.value ?? '',
145
+ selectedLanguage.value ?? '',
146
+ organization.organizationId,
147
+ ],
148
+ query: () => compilationApi.getParameters(groupId.value ?? '', selectedLanguage.value ?? ''),
149
+ enabled: parametersEnabled,
150
+ })
151
+
152
+ // Same query as WhatsappPreview, so the Preview step reuses the cached values.
153
+ const { data: resolvedData } = useResolvedParametersQuery(
154
+ () => groupId.value ?? '',
155
+ () => selectedLanguage.value ?? '',
156
+ )
157
+
158
+ const parameterKeys = computed(() =>
159
+ (parametersData.value?.parameters ?? []).filter(isOperatorParameter),
160
+ )
161
+
162
+ /** Organization-level values, shown as input placeholders. */
163
+ const placeholders = computed<Record<string, string>>(() => resolvedData.value?.parameters ?? {})
164
+
165
+ /**
166
+ * One entry per visible key; prefill seeds new keys, unknown prefill keys
167
+ * are ignored. Rebuilt only once the keys are known, so a language switch
168
+ * keeps what the operator typed while the next list loads.
169
+ */
170
+ const parameterValues = ref<Record<string, string>>({})
171
+ watch(
172
+ parameterKeys,
173
+ (keys) => {
174
+ if (!parametersData.value) return
175
+ const prefill = toValue(options.prefill) ?? {}
176
+ const next: Record<string, string> = {}
177
+ for (const key of keys) next[key] = parameterValues.value[key] ?? prefill[key] ?? ''
178
+ parameterValues.value = next
179
+ },
180
+ { immediate: true },
181
+ )
182
+
183
+ const missing = computed(() =>
184
+ parameterKeys.value.filter((k) => !parameterValues.value[k]?.trim()),
185
+ )
186
+
187
+ // --- Errors ---------------------------------------------------------------
188
+ const flowError = ref<MessageSendFlowError>()
189
+
190
+ function fail(code: MessageSendFlowErrorCode, details?: unknown) {
191
+ const error: MessageSendFlowError = {
192
+ code,
193
+ message: t(`send_flow.errors.${code}`, { name: group.value?.name ?? '' }),
194
+ details,
195
+ }
196
+ flowError.value = error
197
+ trackEvent('send_flow.error', { code })
198
+ emit('error', error)
199
+ }
200
+
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
+ ) {
214
+ flowError.value = undefined
215
+ }
216
+ })
217
+
218
+ // --- Compile (once, on Confirm) --------------------------------------------
219
+ const compiling = ref(false)
220
+ const canConfirm = computed(() => sendable.value && !!selectedMessage.value && !compiling.value)
221
+
222
+ async function confirm() {
223
+ flowError.value = undefined
224
+ if (!groupId.value || !selectedLanguage.value || !group.value) return
225
+
226
+ if (missing.value.length > 0) {
227
+ fail('MISSING_PARAMETERS', { missing: missing.value })
228
+ return
229
+ }
230
+
231
+ trackEvent('send_flow.confirm', {
232
+ message_name: group.value.name,
233
+ message_category: group.value.category,
234
+ language: selectedLanguage.value,
235
+ parameters_count: parameterKeys.value.length,
236
+ })
237
+
238
+ compiling.value = true
239
+ try {
240
+ const res = await compilationApi.compile(
241
+ groupId.value,
242
+ {
243
+ language: selectedLanguage.value,
244
+ parameters: { ...parameterValues.value },
245
+ strict: true,
246
+ strict_language: true,
247
+ brand_id: toValue(options.brandId),
248
+ },
249
+ { skipErrorToast: true },
250
+ )
251
+ if (!('message' in res)) {
252
+ fail('COMPILE_FAILED', res)
253
+ return
254
+ }
255
+ emit('compiled', res.message)
256
+ } catch (e) {
257
+ const { status, data } = e as { status?: number; data?: { code?: string } }
258
+ // 401 reaches the host through `composer:auth-error`; nothing to add here.
259
+ if (status === 401) return
260
+ const code = data?.code as MessageSendFlowErrorCode | undefined
261
+ fail(code && BACKEND_CODES.has(code) ? code : 'COMPILE_FAILED', data ?? e)
262
+ } finally {
263
+ compiling.value = false
264
+ }
265
+ }
266
+
267
+ return {
268
+ group,
269
+ groupLoading,
270
+ languages,
271
+ sendable,
272
+ selectedLanguage,
273
+ selectedMessage,
274
+ parameterKeys,
275
+ parametersLoading,
276
+ placeholders,
277
+ parameterValues,
278
+ flowError,
279
+ compiling,
280
+ canConfirm,
281
+ confirm,
282
+ trackEvent,
283
+ }
284
+ }
@@ -0,0 +1,78 @@
1
+ import type { MaybeRefOrGetter, Ref, WatchSource } from 'vue'
2
+ import type { RenderType } from '@dev.smartpricing/message-composer-utils/types'
3
+ import type { ListMessageGroupsOptions } from '~/api/messageGroups'
4
+ import { useMessageGroupListQuery } from '~/queries/messageGroups'
5
+
6
+ export const SEARCH_DEBOUNCE_MS = 300
7
+
8
+ type PagedListFilters = Omit<
9
+ ListMessageGroupsOptions,
10
+ 'search' | 'page' | 'per_page' | 'render_type'
11
+ >
12
+
13
+ export interface PagedMessageGroupsOptions {
14
+ /** Raw search term as typed; it is debounced and trimmed before it reaches the request. */
15
+ search: Ref<string>
16
+ /** Current page. Lands on one whenever the search settles or a `resetOn` source changes. */
17
+ page: Ref<number>
18
+ perPage: number
19
+ renderType: MaybeRefOrGetter<RenderType | undefined>
20
+ /** Remaining filters and sorting, merged into the request as they are. */
21
+ filters?: MaybeRefOrGetter<PagedListFilters>
22
+ /** Further sources whose change lands on page one, besides the search and render type. */
23
+ resetOn?: WatchSource[]
24
+ /** When false no request is made (the query stays pending). */
25
+ enabled?: MaybeRefOrGetter<boolean>
26
+ /** Lists this Organization instead of the resolved one. */
27
+ organizationId?: MaybeRefOrGetter<string | undefined>
28
+ }
29
+
30
+ /**
31
+ * One server-side paginated Message Group list (ADR 0012) as a reactive source:
32
+ * debounced search, page reset, request options and the query itself. The
33
+ * library table and the in-composer picker both build on it and own only what
34
+ * differs (filters, page size, how rows are shown).
35
+ */
36
+ export function usePagedMessageGroups(options: PagedMessageGroupsOptions) {
37
+ const debouncedSearch = refDebounced(options.search, SEARCH_DEBOUNCE_MS)
38
+
39
+ const listOptions = computed<ListMessageGroupsOptions>(() => ({
40
+ ...toValue(options.filters),
41
+ render_type: toValue(options.renderType),
42
+ search: debouncedSearch.value.trim() || undefined,
43
+ page: options.page.value,
44
+ per_page: options.perPage,
45
+ }))
46
+
47
+ const query = useMessageGroupListQuery(listOptions, {
48
+ enabled: options.enabled,
49
+ organizationId: options.organizationId,
50
+ })
51
+
52
+ // The reset happens in the same tick as the change (sync flush) so the query
53
+ // sees one key change, not two, and the user never opens an empty page.
54
+ const organization = useEffectiveOrganizationStore()
55
+ watch(
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
+ ],
63
+ () => {
64
+ options.page.value = 1
65
+ },
66
+ { flush: 'sync' },
67
+ )
68
+
69
+ return {
70
+ debouncedSearch,
71
+ listOptions,
72
+ query,
73
+ rows: computed(() => query.data.value?.data ?? []),
74
+ meta: computed(() => query.data.value?.meta),
75
+ /** True on the first load and while a new page or filter set is fetching over previous data. */
76
+ loading: computed(() => query.isPending.value || query.isPlaceholderData.value),
77
+ }
78
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  MessageStatus,
14
14
  MessageGroup,
15
15
  } from '@dev.smartpricing/message-composer-utils/types'
16
+ import { useAppContextStore } from '@/stores/appContext'
16
17
 
17
18
  interface TemplateData<T = Message | PostEntity<Message>> {
18
19
  messageGroup: { name: string; category: MessageGroupCategory; brand_id?: string | null }
@@ -27,6 +28,8 @@ interface UseTemplateOperationsOptions {
27
28
  export function useTemplateOperations(options: UseTemplateOperationsOptions = {}) {
28
29
  const saving = ref(false)
29
30
  const saved = ref(false)
31
+ const appContext = useAppContextStore()
32
+ const organization = useEffectiveOrganizationStore()
30
33
 
31
34
  // Hoist composables to setup scope (they use inject() internally)
32
35
  const { mutateAsync: createMessageGroup } = useCreateMessageGroupMutation()
@@ -39,9 +42,20 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
39
42
  async function createTemplate(data: TemplateData, status: MessageStatus = 'draft') {
40
43
  saving.value = true
41
44
  try {
42
- const createdMessageGroup = await createMessageGroup(
43
- data.messageGroup as PostEntity<MessageGroup>,
44
- )
45
+ // Every Message Group belongs to the context Product (ADR 0011).
46
+ const product = appContext.product
47
+ if (!product) {
48
+ throw new Error(
49
+ '[message-composer] Cannot create a template: no Product in the composer context',
50
+ )
51
+ }
52
+ const createdMessageGroup = await createMessageGroup({
53
+ ...data.messageGroup,
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(),
58
+ } as PostEntity<MessageGroup>)
45
59
 
46
60
  if (createdMessageGroup?.id) {
47
61
  for (const message of Object.values(data.messages)) {
@@ -102,7 +116,12 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
102
116
  try {
103
117
  const result = await duplicateMessageGroup({
104
118
  id: sourceId,
105
- data: newName ? { name: newName } : undefined,
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
+ },
106
125
  })
107
126
 
108
127
  saved.value = true