@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
@@ -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
- filterFn: (row, columnId, filterValue) => {
100
- if (filterValue === 'all') return true
101
- return row.original.category.toLowerCase().includes(filterValue.toLowerCase())
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
- header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.preview')),
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
- header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.status')),
113
- filterFn: (row, columnId, filterValue) => {
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
- const sorting = ref([
148
- {
149
- id: 'updated_at',
150
- desc: true,
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 props = defineProps<{
155
- messageGroups: MessageGroupWithMessageSummary[]
156
- loading: boolean
157
- renderType: RenderType
158
- }>()
166
+ const sorting = ref(toTableSorting(props.sorting))
159
167
 
160
- const filteredMessageGroups = computed(() => {
161
- if (appContext.isAdmin) return props.messageGroups
162
- return props.messageGroups.filter((mg) => mg.visibility !== 'internal')
163
- })
168
+ watch(
169
+ () => props.sorting,
170
+ (value) => {
171
+ sorting.value = toTableSorting(value)
172
+ },
173
+ )
164
174
 
165
- const categoryItems = computed(() => {
166
- return [
167
- {
168
- label: t('common.all'),
169
- value: 'all',
170
- },
171
- ...MESSAGE_GROUP_CATEGORIES.map((category) => ({
172
- label: t(`categories.${category}`),
173
- value: category,
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
- const statusItems = computed(() => {
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
- label: t('common.all'),
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 table = useTemplateRef('table')
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: (table.value?.tableApi?.getColumn('name')?.getFilterValue() as string) || '',
216
- category: (table.value?.tableApi?.getColumn('category')?.getFilterValue() as string) || '',
217
- status: (table.value?.tableApi?.getColumn('status')?.getFilterValue() as string) || '',
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
- table.value?.tableApi?.getColumn('name')?.setFilterValue(value)
235
+ emit('update:filters', { search: value })
229
236
  trackSearchFilter(value)
230
237
  }
231
238
 
232
- function onCategoryFilter(value: string) {
233
- table.value?.tableApi?.getColumn('category')?.setFilterValue(value)
234
- trackFilters({ category: value })
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
- function onStatusFilter(value: string) {
238
- table.value?.tableApi?.getColumn('status')?.setFilterValue(value)
239
- trackFilters({ status: value })
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="table?.tableApi?.getColumn('name')?.getFilterValue() as string"
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="table?.tableApi?.getColumn('category')?.getFilterValue() as string"
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="onCategoryFilter($event)"
275
+ @update:model-value="onFilter('category', $event)"
264
276
  />
265
277
  <USelect
266
- :model-value="table?.tableApi?.getColumn('status')?.getFilterValue() as string"
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="onStatusFilter($event)"
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="filteredMessageGroups"
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
- const mentionRegex = new RegExp(`{{${key}}}`, 'g')
37
- replacedHtml = replacedHtml.replace(mentionRegex, value || `{{${key}}}`)
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 :message="message" :message-group-id="messageGroupId" />
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
- product?: Ref<AppContextConfig['product']>
16
- strategy?: Ref<AppContextConfig['strategy']>
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
- const SERVER_FALLBACK: ComposerContext = {}
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
+ }