@dev.smartpricing/message-composer-layer 4.3.2-fix.1 → 4.4.1-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +184 -48
  2. package/app/api/client.ts +12 -0
  3. package/app/api/compilation.ts +13 -3
  4. package/app/api/dynamicValues.ts +5 -2
  5. package/app/api/messageGroups.ts +23 -12
  6. package/app/components/Common/AppContextErrors.vue +30 -0
  7. package/app/components/Common/HtmlPreviewIframe.vue +17 -3
  8. package/app/components/Common/MessageGroupSelect.vue +153 -26
  9. package/app/components/Common/MessagePreviewSlideover.vue +3 -0
  10. package/app/components/Composer/MessageLibrary.vue +17 -7
  11. package/app/components/MessageSendFlow/EmailPreview.vue +78 -0
  12. package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
  13. package/app/components/MessageSendFlow/MessageSendFlow.vue +252 -0
  14. package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
  15. package/app/components/TemplateList/BaseTable.vue +156 -108
  16. package/app/components/Whatsapp/Preview.vue +12 -4
  17. package/app/components/Whatsapp/PreviewPanel.vue +7 -1
  18. package/app/composables/composerContext.ts +4 -2
  19. package/app/composables/useMessageGroupList.ts +186 -0
  20. package/app/composables/useMessageSendFlow.ts +246 -0
  21. package/app/composables/usePagedMessageGroups.ts +66 -0
  22. package/app/composables/useTemplateOperations.ts +13 -3
  23. package/app/layouts/default.vue +1 -0
  24. package/app/queries/compilation.ts +1 -1
  25. package/app/queries/dynamicValues.ts +8 -2
  26. package/app/queries/messageGroups.ts +29 -16
  27. package/app/stores/appContext.ts +12 -6
  28. package/app/types/tracking.ts +17 -0
  29. package/app/utils/testIds.const.ts +29 -0
  30. package/i18n/locales/de.ts +56 -2
  31. package/i18n/locales/en.ts +53 -2
  32. package/i18n/locales/es.ts +54 -2
  33. package/i18n/locales/fr.ts +53 -2
  34. package/i18n/locales/it.ts +55 -2
  35. 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,246 @@
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
+ | 'MISSING_PARAMETERS'
21
+ | 'TEMPLATE_NOT_SUBMITTED'
22
+ | 'TEMPLATE_NOT_APPROVED'
23
+ | 'COMPILE_FAILED'
24
+
25
+ const BACKEND_CODES = new Set<MessageSendFlowErrorCode>([
26
+ 'TEMPLATE_NOT_SUBMITTED',
27
+ 'TEMPLATE_NOT_APPROVED',
28
+ 'MISSING_PARAMETERS',
29
+ ])
30
+
31
+ export interface MessageSendFlowError {
32
+ code: MessageSendFlowErrorCode
33
+ /** The layer's translated text, the same string the inline alert shows. */
34
+ message: string
35
+ /** Missing keys (client-side) or the backend error body. */
36
+ details?: unknown
37
+ }
38
+
39
+ export interface MessageSendFlowEmits {
40
+ compiled: [message: SendableMessage]
41
+ cancel: []
42
+ error: [error: MessageSendFlowError]
43
+ }
44
+
45
+ export interface MessageSendFlowLanguage {
46
+ code: string
47
+ status: MessageStatus
48
+ approved: boolean
49
+ }
50
+
51
+ /** What an operator must fill; `brand_*` keys are baked server-side. */
52
+ export function isOperatorParameter(key: string): boolean {
53
+ return !key.startsWith('brand_')
54
+ }
55
+
56
+ /**
57
+ * Data flow of the Message Send Flow (spec #67): Sendable group → approved
58
+ * language → parameters → one `/compile` on Confirm. The component owns the
59
+ * steps and the buttons; the host owns the container and the send.
60
+ */
61
+ export function useMessageSendFlow(options: {
62
+ groupId: Ref<string | undefined>
63
+ renderType: MaybeRefOrGetter<RenderType>
64
+ preferredLanguage: MaybeRefOrGetter<string | undefined>
65
+ prefill: MaybeRefOrGetter<Record<string, string> | undefined>
66
+ brandId: MaybeRefOrGetter<string | undefined>
67
+ emit: <K extends keyof MessageSendFlowEmits>(event: K, ...args: MessageSendFlowEmits[K]) => void
68
+ }) {
69
+ const { groupId, emit } = options
70
+ const { t } = useI18n()
71
+ const { trackEvent } = useTracking()
72
+
73
+ // --- Group -------------------------------------------------------------
74
+ const { data: group, isLoading: groupLoading } = useMessageGroupQuery(() => groupId.value ?? '', {
75
+ enabled: () => !!groupId.value,
76
+ })
77
+
78
+ const messages = computed(() =>
79
+ (group.value?.messages ?? []).filter((m) => m.render_type === toValue(options.renderType)),
80
+ )
81
+
82
+ const languages = computed<MessageSendFlowLanguage[]>(() =>
83
+ messages.value.map((m) => ({
84
+ code: m.language_id,
85
+ status: m.status,
86
+ approved: m.status === 'approved',
87
+ })),
88
+ )
89
+
90
+ const sendable = computed(() => languages.value.some((l) => l.approved))
91
+ const notSendable = computed(
92
+ () => !!groupId.value && !!group.value && !groupLoading.value && !sendable.value,
93
+ )
94
+
95
+ // --- Language rule: preferred if approved, else first approved -----------
96
+ const selectedLanguage = ref<string>()
97
+ watch(
98
+ [messages, () => toValue(options.preferredLanguage)],
99
+ ([msgs, preferred]) => {
100
+ const approved = msgs.filter((m) => m.status === 'approved')
101
+ const match = approved.find((m) => m.language_id === preferred) ?? approved[0]
102
+ selectedLanguage.value = match?.language_id
103
+ },
104
+ { immediate: true },
105
+ )
106
+
107
+ const selectedMessage = computed(
108
+ () =>
109
+ messages.value.find((m) => m.language_id === selectedLanguage.value) as
110
+ | PostEntity<WhatsappMessage>
111
+ | PostEntity<EmailMessage>
112
+ | undefined,
113
+ )
114
+
115
+ // --- Parameters ----------------------------------------------------------
116
+ const parametersEnabled = () => !!groupId.value && !!selectedLanguage.value
117
+
118
+ const { data: parametersData, isLoading: parametersLoading } = useQuery({
119
+ key: () => ['send-flow', 'parameters', groupId.value ?? '', selectedLanguage.value ?? ''],
120
+ query: () => compilationApi.getParameters(groupId.value ?? '', selectedLanguage.value ?? ''),
121
+ enabled: parametersEnabled,
122
+ })
123
+
124
+ // Same query as WhatsappPreview, so the Preview step reuses the cached values.
125
+ const { data: resolvedData } = useResolvedParametersQuery(
126
+ () => groupId.value ?? '',
127
+ () => selectedLanguage.value ?? '',
128
+ )
129
+
130
+ const parameterKeys = computed(() =>
131
+ (parametersData.value?.parameters ?? []).filter(isOperatorParameter),
132
+ )
133
+
134
+ /** Organization-level values, shown as input placeholders. */
135
+ const placeholders = computed<Record<string, string>>(() => resolvedData.value?.parameters ?? {})
136
+
137
+ /**
138
+ * One entry per visible key; prefill seeds new keys, unknown prefill keys
139
+ * are ignored. Rebuilt only once the keys are known, so a language switch
140
+ * keeps what the operator typed while the next list loads.
141
+ */
142
+ const parameterValues = ref<Record<string, string>>({})
143
+ watch(
144
+ parameterKeys,
145
+ (keys) => {
146
+ if (!parametersData.value) return
147
+ const prefill = toValue(options.prefill) ?? {}
148
+ const next: Record<string, string> = {}
149
+ for (const key of keys) next[key] = parameterValues.value[key] ?? prefill[key] ?? ''
150
+ parameterValues.value = next
151
+ },
152
+ { immediate: true },
153
+ )
154
+
155
+ const missing = computed(() =>
156
+ parameterKeys.value.filter((k) => !parameterValues.value[k]?.trim()),
157
+ )
158
+
159
+ // --- Errors ---------------------------------------------------------------
160
+ const flowError = ref<MessageSendFlowError>()
161
+
162
+ function fail(code: MessageSendFlowErrorCode, details?: unknown) {
163
+ const error: MessageSendFlowError = {
164
+ code,
165
+ message: t(`send_flow.errors.${code}`, { name: group.value?.name ?? '' }),
166
+ details,
167
+ }
168
+ flowError.value = error
169
+ trackEvent('send_flow.error', { code })
170
+ emit('error', error)
171
+ }
172
+
173
+ watch(notSendable, (value) => {
174
+ if (value) {
175
+ fail('NOT_SENDABLE')
176
+ } else if (flowError.value?.code === 'NOT_SENDABLE') {
177
+ flowError.value = undefined
178
+ }
179
+ })
180
+
181
+ // --- Compile (once, on Confirm) --------------------------------------------
182
+ const compiling = ref(false)
183
+ const canConfirm = computed(() => !!selectedMessage.value && !compiling.value)
184
+
185
+ async function confirm() {
186
+ flowError.value = undefined
187
+ if (!groupId.value || !selectedLanguage.value || !group.value) return
188
+
189
+ if (missing.value.length > 0) {
190
+ fail('MISSING_PARAMETERS', { missing: missing.value })
191
+ return
192
+ }
193
+
194
+ trackEvent('send_flow.confirm', {
195
+ message_name: group.value.name,
196
+ message_category: group.value.category,
197
+ language: selectedLanguage.value,
198
+ parameters_count: parameterKeys.value.length,
199
+ })
200
+
201
+ compiling.value = true
202
+ try {
203
+ const res = await compilationApi.compile(
204
+ groupId.value,
205
+ {
206
+ language: selectedLanguage.value,
207
+ parameters: { ...parameterValues.value },
208
+ strict: true,
209
+ strict_language: true,
210
+ brand_id: toValue(options.brandId),
211
+ },
212
+ { skipErrorToast: true },
213
+ )
214
+ if (!('message' in res)) {
215
+ fail('COMPILE_FAILED', res)
216
+ return
217
+ }
218
+ emit('compiled', res.message)
219
+ } catch (e) {
220
+ const { status, data } = e as { status?: number; data?: { code?: string } }
221
+ // 401 reaches the host through `composer:auth-error`; nothing to add here.
222
+ if (status === 401) return
223
+ const code = data?.code as MessageSendFlowErrorCode | undefined
224
+ fail(code && BACKEND_CODES.has(code) ? code : 'COMPILE_FAILED', data ?? e)
225
+ } finally {
226
+ compiling.value = false
227
+ }
228
+ }
229
+
230
+ return {
231
+ group,
232
+ groupLoading,
233
+ languages,
234
+ sendable,
235
+ selectedLanguage,
236
+ selectedMessage,
237
+ parameterKeys,
238
+ parametersLoading,
239
+ placeholders,
240
+ parameterValues,
241
+ flowError,
242
+ compiling,
243
+ canConfirm,
244
+ confirm,
245
+ }
246
+ }
@@ -0,0 +1,66 @@
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
+ }
27
+
28
+ /**
29
+ * One server-side paginated Message Group list (ADR 0012) as a reactive source:
30
+ * debounced search, page reset, request options and the query itself. The
31
+ * library table and the in-composer picker both build on it and own only what
32
+ * differs (filters, page size, how rows are shown).
33
+ */
34
+ export function usePagedMessageGroups(options: PagedMessageGroupsOptions) {
35
+ const debouncedSearch = refDebounced(options.search, SEARCH_DEBOUNCE_MS)
36
+
37
+ const listOptions = computed<ListMessageGroupsOptions>(() => ({
38
+ ...toValue(options.filters),
39
+ render_type: toValue(options.renderType),
40
+ search: debouncedSearch.value.trim() || undefined,
41
+ page: options.page.value,
42
+ per_page: options.perPage,
43
+ }))
44
+
45
+ const query = useMessageGroupListQuery(listOptions, { enabled: options.enabled })
46
+
47
+ // The reset happens in the same tick as the change (sync flush) so the query
48
+ // sees one key change, not two, and the user never opens an empty page.
49
+ watch(
50
+ [debouncedSearch, () => toValue(options.renderType), ...(options.resetOn ?? [])],
51
+ () => {
52
+ options.page.value = 1
53
+ },
54
+ { flush: 'sync' },
55
+ )
56
+
57
+ return {
58
+ debouncedSearch,
59
+ listOptions,
60
+ query,
61
+ rows: computed(() => query.data.value?.data ?? []),
62
+ meta: computed(() => query.data.value?.meta),
63
+ /** True on the first load and while a new page or filter set is fetching over previous data. */
64
+ loading: computed(() => query.isPending.value || query.isPlaceholderData.value),
65
+ }
66
+ }
@@ -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,7 @@ 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()
30
32
 
31
33
  // Hoist composables to setup scope (they use inject() internally)
32
34
  const { mutateAsync: createMessageGroup } = useCreateMessageGroupMutation()
@@ -39,9 +41,17 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
39
41
  async function createTemplate(data: TemplateData, status: MessageStatus = 'draft') {
40
42
  saving.value = true
41
43
  try {
42
- const createdMessageGroup = await createMessageGroup(
43
- data.messageGroup as PostEntity<MessageGroup>,
44
- )
44
+ // Every Message Group belongs to the context Product (ADR 0011).
45
+ const product = appContext.product
46
+ if (!product) {
47
+ throw new Error(
48
+ '[message-composer] Cannot create a template: no Product in the composer context',
49
+ )
50
+ }
51
+ const createdMessageGroup = await createMessageGroup({
52
+ ...data.messageGroup,
53
+ product,
54
+ } as PostEntity<MessageGroup>)
45
55
 
46
56
  if (createdMessageGroup?.id) {
47
57
  for (const message of Object.values(data.messages)) {
@@ -1,5 +1,6 @@
1
1
  <template>
2
2
  <SNavigationPage :panel-props="{ ui: { body: 'p-0 sm:p-0 gap-0 sm:gap-0 overflow-hidden' } }">
3
+ <CommonAppContextErrors />
3
4
  <slot />
4
5
  </SNavigationPage>
5
6
  </template>
@@ -37,7 +37,7 @@ export function useResolvedParametersQuery(
37
37
  export function useCompileEmailMutation() {
38
38
  return useMutation({
39
39
  mutation: ({ messageGroupId, data }: { messageGroupId: string; data: CompileMessageRequest }) =>
40
- compilationApi.compileEmail(messageGroupId, data),
40
+ compilationApi.compile(messageGroupId, data),
41
41
  })
42
42
  }
43
43
 
@@ -1,4 +1,5 @@
1
1
  import { useQuery } from '@pinia/colada'
2
+ import type { DynamicValuesResponse } from '@dev.smartpricing/message-composer-utils/types'
2
3
  import { dynamicValuesApi } from '~/api'
3
4
  import { useAppContextStore } from '~/stores/appContext'
4
5
 
@@ -6,8 +7,13 @@ export function useDynamicValuesQuery() {
6
7
  const { product, mainLanguage } = storeToRefs(useAppContextStore())
7
8
 
8
9
  return useQuery({
9
- key: () => ['dynamic-values', product.value, mainLanguage.value],
10
- query: () => dynamicValuesApi.get(product.value || 'browser', mainLanguage.value || 'en'),
10
+ key: () => ['dynamic-values', product.value ?? '', mainLanguage.value],
11
+ // No Product, no request: the picker stays empty and the store lists the
12
+ // missing Product in `validationErrors`.
13
+ query: () => {
14
+ if (!product.value) return Promise.resolve<DynamicValuesResponse>({})
15
+ return dynamicValuesApi.get(product.value, mainLanguage.value || 'en')
16
+ },
11
17
  staleTime: 30 * 60 * 1000,
12
18
  })
13
19
  }
@@ -1,39 +1,52 @@
1
1
  import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
2
+ import type { MaybeRefOrGetter } from 'vue'
2
3
  import type {
3
4
  DuplicateMessageGroupBody,
4
5
  MessageGroup,
5
6
  MessageGroupWithMessageSummary,
6
7
  PostEntity,
7
- RenderType,
8
8
  } from '@dev.smartpricing/message-composer-utils/types'
9
9
  import { messageGroupsApi } from '~/api'
10
+ import type { ListMessageGroupsOptions } from '~/api/messageGroups'
10
11
 
11
12
  // QUERIES
12
- export function useMessageGroupsQuery(
13
- options?: MaybeRefOrGetter<{
14
- renderType?: RenderType
15
- tags?: string[]
16
- tagsMode?: 'and' | 'or'
17
- }>,
18
- _queryOptions?: { polling?: boolean },
13
+
14
+ /**
15
+ * Colada merges `{ ...defaults, ...options }`, so forwarding `enabled: undefined`
16
+ * overrides its default `true` and silently disables the query: it then only
17
+ * runs on an explicit `refetch` (the library's 15 s poll, mutation invalidations).
18
+ * Forward `enabled` only when the caller set it.
19
+ */
20
+ function enabledOption(enabled?: MaybeRefOrGetter<boolean>) {
21
+ return enabled === undefined ? {} : { enabled }
22
+ }
23
+ /**
24
+ * One page of the server-side filtered list, in the Laravel envelope (ADR 0012).
25
+ * The previous page stays as placeholder data while the next one loads, so
26
+ * paging and filtering never flash an empty table.
27
+ */
28
+ export function useMessageGroupListQuery(
29
+ options: MaybeRefOrGetter<ListMessageGroupsOptions>,
30
+ queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
19
31
  ) {
20
- const query = useQuery({
32
+ return useQuery({
21
33
  key: () => ['message-groups', JSON.stringify(toValue(options))],
22
- query: () => messageGroupsApi.getAll(toValue(options)),
34
+ query: () => messageGroupsApi.list(toValue(options)),
23
35
  staleTime: 0,
36
+ placeholderData: (previousData) => previousData,
37
+ ...enabledOption(queryOptions.enabled),
24
38
  })
25
-
26
- // TODO: Implement polling when Pinia Colada supports it
27
- // For now, polling can be done manually in the component using setInterval + refetch
28
-
29
- return query
30
39
  }
31
40
 
32
- export function useMessageGroupQuery(id: MaybeRefOrGetter<string>) {
41
+ export function useMessageGroupQuery(
42
+ id: MaybeRefOrGetter<string>,
43
+ queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
44
+ ) {
33
45
  return useQuery({
34
46
  key: () => ['message-groups', toValue(id)],
35
47
  query: () => messageGroupsApi.getById(toValue(id)),
36
48
  staleTime: 2 * 60 * 1000,
49
+ ...enabledOption(queryOptions.enabled),
37
50
  })
38
51
  }
39
52
 
@@ -1,7 +1,8 @@
1
1
  import { ref, computed, watch } from 'vue'
2
- import type {
3
- AppContextConfig,
4
- AppContextState,
2
+ import {
3
+ MESSAGE_GROUP_PRODUCTS,
4
+ type AppContextConfig,
5
+ type AppContextState,
5
6
  } from '@dev.smartpricing/message-composer-utils/types'
6
7
 
7
8
  export const useAppContextStore = defineStore('appContext', () => {
@@ -11,9 +12,10 @@ export const useAppContextStore = defineStore('appContext', () => {
11
12
 
12
13
  // Proxy from injection with defaults (same interface as before)
13
14
  const organization_id = computed(() => ctx.organization_id?.value ?? '')
14
- const product = computed<NonNullable<AppContextConfig['product']>>(
15
- () => ctx.product?.value ?? 'browser',
16
- )
15
+ // The Product is required by the context type (ADR 0011), but a host can
16
+ // still omit it at runtime: read defensively and list it in
17
+ // `validationErrors` rather than crashing or falling back to a fake Product.
18
+ const product = computed<AppContextConfig['product'] | undefined>(() => ctx.product?.value)
17
19
  const strategy = computed<NonNullable<AppContextConfig['strategy']>>(
18
20
  () => ctx.strategy?.value ?? 'native',
19
21
  )
@@ -59,6 +61,10 @@ export const useAppContextStore = defineStore('appContext', () => {
59
61
  const validationErrors = computed<Record<string, string>>(() => {
60
62
  const errors: Record<string, string> = {}
61
63
 
64
+ if (!product.value) {
65
+ errors.product = `Product is required: pass \`product\` (${MESSAGE_GROUP_PRODUCTS.join(' | ')}) in the composer context`
66
+ }
67
+
62
68
  if (channels.value?.includes('whatsapp') && !metaWabaId.value) {
63
69
  errors.metaConfig = 'Meta WABA ID is required for WhatsApp'
64
70
  }