@dev.smartpricing/message-composer-layer 4.4.1-next.0 → 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 (49) hide show
  1. package/README.md +51 -5
  2. package/app/api/brands.ts +20 -13
  3. package/app/api/client.ts +3 -6
  4. package/app/api/dynamicValues.ts +2 -1
  5. package/app/api/media.ts +6 -3
  6. package/app/api/messageGroups.ts +6 -2
  7. package/app/api/meta.ts +26 -4
  8. package/app/api/organization.ts +10 -1
  9. package/app/api/tracking.ts +2 -2
  10. package/app/components/Common/AppContextErrors.vue +2 -2
  11. package/app/components/Common/MessageGroupSelect.vue +13 -2
  12. package/app/components/Common/OrganizationGate.vue +51 -0
  13. package/app/components/Common/OrganizationSelect.vue +27 -0
  14. package/app/components/Common/WhatsappNotConfigured.vue +20 -0
  15. package/app/components/Composer/EmailComposer.vue +4 -0
  16. package/app/components/Composer/WabaSelectModal.vue +75 -0
  17. package/app/components/Composer/WhatsappComposer.vue +78 -3
  18. package/app/components/MessageSendFlow/EmailPreview.vue +3 -0
  19. package/app/components/MessageSendFlow/MessageSendFlow.vue +15 -2
  20. package/app/composables/composerContext.ts +5 -5
  21. package/app/composables/useEffectiveOrganization.ts +34 -0
  22. package/app/composables/useMessageGroupWaba.ts +73 -0
  23. package/app/composables/useMessageSendFlow.ts +46 -8
  24. package/app/composables/usePagedMessageGroups.ts +14 -2
  25. package/app/composables/useTemplateOperations.ts +10 -1
  26. package/app/composables/useTracking.ts +14 -5
  27. package/app/composables/useWhatsappConfiguration.ts +32 -0
  28. package/app/layouts/default.vue +4 -1
  29. package/app/pages/brands.vue +1 -0
  30. package/app/pages/images.vue +4 -0
  31. package/app/pages/index.vue +1 -0
  32. package/app/pages/preview/[id].vue +2 -0
  33. package/app/pages/template/whatsapp/[id].vue +1 -0
  34. package/app/queries/brands.ts +27 -7
  35. package/app/queries/compilation.ts +15 -2
  36. package/app/queries/dynamicValues.ts +13 -2
  37. package/app/queries/media.ts +8 -3
  38. package/app/queries/messageGroups.ts +31 -5
  39. package/app/queries/meta.ts +13 -8
  40. package/app/stores/appContext.ts +9 -45
  41. package/app/stores/effectiveOrganization.ts +128 -0
  42. package/app/stores/emailEditor.ts +5 -1
  43. package/app/stores/metaSubmission.ts +7 -17
  44. package/i18n/locales/de.ts +28 -0
  45. package/i18n/locales/en.ts +27 -0
  46. package/i18n/locales/es.ts +27 -0
  47. package/i18n/locales/fr.ts +28 -0
  48. package/i18n/locales/it.ts +27 -0
  49. package/package.json +2 -2
@@ -3,20 +3,32 @@ import type {
3
3
  BrandCreateBody,
4
4
  BrandUpdateBody,
5
5
  } from '@dev.smartpricing/message-composer-utils/types'
6
- import { brandsApi, propertiesApi } from '~/api'
6
+ import { brandsApi, propertiesApi, type BrandPreviewRequest } from '~/api'
7
7
 
8
8
  // QUERIES
9
9
  export function useBrandsQuery(options?: {
10
10
  sort_by?: 'created_at' | 'name'
11
11
  direction?: 'asc' | 'desc'
12
12
  }) {
13
+ const organization = useEffectiveOrganizationStore()
14
+
13
15
  return useQuery({
14
- key: () => ['brands', options?.sort_by ?? '', options?.direction ?? ''],
15
- query: () => brandsApi.getAll(options),
16
+ key: () => [
17
+ 'brands',
18
+ options?.sort_by ?? '',
19
+ options?.direction ?? '',
20
+ organization.organizationId,
21
+ ],
22
+ query: () =>
23
+ brandsApi.getAll({ ...options, organization_id: organization.requireOrganizationId() }),
16
24
  staleTime: 2 * 60 * 1000,
17
25
  })
18
26
  }
19
27
 
28
+ /**
29
+ * A Brand by id. No Organization in the key: the request names none, it acts
30
+ * on the Organization of the Brand itself (ADR 0014).
31
+ */
20
32
  export function useBrandQuery(id: MaybeRefOrGetter<string>) {
21
33
  return useQuery({
22
34
  key: () => ['brands', toValue(id)],
@@ -27,19 +39,24 @@ export function useBrandQuery(id: MaybeRefOrGetter<string>) {
27
39
  }
28
40
 
29
41
  export function usePropertiesQuery() {
42
+ const organization = useEffectiveOrganizationStore()
43
+
30
44
  return useQuery({
31
- key: () => ['properties'],
32
- query: () => propertiesApi.getAll(),
45
+ key: () => ['properties', organization.organizationId],
46
+ query: () => propertiesApi.getAll(organization.requireOrganizationId()),
33
47
  staleTime: 10 * 60 * 1000,
34
48
  })
35
49
  }
36
50
 
37
51
  // MUTATIONS
52
+ /** A new Brand names its Organization (ADR 0014): the Effective one. */
38
53
  export function useCreateBrandMutation() {
39
54
  const queryCache = useQueryCache()
55
+ const organization = useEffectiveOrganizationStore()
40
56
 
41
57
  return useMutation({
42
- mutation: (data: BrandCreateBody) => brandsApi.create(data),
58
+ mutation: (data: BrandCreateBody) =>
59
+ brandsApi.create({ ...data, organization_id: organization.requireOrganizationId() }),
43
60
  onSuccess: () => {
44
61
  queryCache.invalidateQueries({ key: ['brands'] })
45
62
  },
@@ -70,7 +87,10 @@ export function useDeleteBrandMutation() {
70
87
  }
71
88
 
72
89
  export function useBrandPreviewMutation() {
90
+ const organization = useEffectiveOrganizationStore()
91
+
73
92
  return useMutation({
74
- mutation: brandsApi.preview,
93
+ mutation: (data: BrandPreviewRequest) =>
94
+ brandsApi.preview({ ...data, organization_id: organization.requireOrganizationId() }),
75
95
  })
76
96
  }
@@ -5,11 +5,18 @@ import { compilationApi } from '~/api'
5
5
  import type {
6
6
  CompileMessageRequest,
7
7
  PreviewMessageRequest,
8
+ TestEmailRequest,
8
9
  } from '@dev.smartpricing/message-composer-utils/types'
9
10
 
10
11
  export function useSendTestEmailMutation() {
12
+ const organization = useEffectiveOrganizationStore()
13
+
11
14
  return useMutation({
12
- mutation: compilationApi.sendTestEmail,
15
+ mutation: (data: TestEmailRequest) =>
16
+ compilationApi.sendTestEmail({
17
+ ...data,
18
+ organization_id: organization.requireOrganizationId(),
19
+ }),
13
20
  })
14
21
  }
15
22
 
@@ -42,7 +49,13 @@ export function useCompileEmailMutation() {
42
49
  }
43
50
 
44
51
  export function usePreviewEmailMutation() {
52
+ const organization = useEffectiveOrganizationStore()
53
+
45
54
  return useMutation({
46
- mutation: (data: PreviewMessageRequest) => compilationApi.previewEmail(data),
55
+ mutation: (data: PreviewMessageRequest) =>
56
+ compilationApi.previewEmail({
57
+ ...data,
58
+ organization_id: organization.requireOrganizationId(),
59
+ }),
47
60
  })
48
61
  }
@@ -6,13 +6,24 @@ import { useAppContextStore } from '~/stores/appContext'
6
6
  export function useDynamicValuesQuery() {
7
7
  const { product, mainLanguage } = storeToRefs(useAppContextStore())
8
8
 
9
+ const organization = useEffectiveOrganizationStore()
10
+
9
11
  return useQuery({
10
- key: () => ['dynamic-values', product.value ?? '', mainLanguage.value],
12
+ key: () => [
13
+ 'dynamic-values',
14
+ product.value ?? '',
15
+ mainLanguage.value,
16
+ organization.organizationId,
17
+ ],
11
18
  // No Product, no request: the picker stays empty and the store lists the
12
19
  // missing Product in `validationErrors`.
13
20
  query: () => {
14
21
  if (!product.value) return Promise.resolve<DynamicValuesResponse>({})
15
- return dynamicValuesApi.get(product.value, mainLanguage.value || 'en')
22
+ return dynamicValuesApi.get(
23
+ product.value,
24
+ mainLanguage.value || 'en',
25
+ organization.requireOrganizationId(),
26
+ )
16
27
  },
17
28
  staleTime: 30 * 60 * 1000,
18
29
  })
@@ -2,15 +2,20 @@ import { useMutation, useQuery } from '@pinia/colada'
2
2
  import { mediaApi } from '~/api'
3
3
 
4
4
  export function useMediaQuery() {
5
+ const organization = useEffectiveOrganizationStore()
6
+
5
7
  return useQuery({
6
- key: ['media'],
7
- query: mediaApi.list,
8
+ key: () => ['media', organization.organizationId],
9
+ query: () => mediaApi.list(organization.requireOrganizationId()),
8
10
  })
9
11
  }
10
12
 
13
+ /** An uploaded image names its Organization (ADR 0014): the Effective one. */
11
14
  export function useUploadMediaMutation() {
15
+ const organization = useEffectiveOrganizationStore()
16
+
12
17
  return useMutation({
13
- mutation: mediaApi.upload,
18
+ mutation: (file: File) => mediaApi.upload(file, organization.requireOrganizationId()),
14
19
  })
15
20
  }
16
21
 
@@ -23,21 +23,47 @@ function enabledOption(enabled?: MaybeRefOrGetter<boolean>) {
23
23
  /**
24
24
  * One page of the server-side filtered list, in the Laravel envelope (ADR 0012).
25
25
  * The previous page stays as placeholder data while the next one loads, so
26
- * paging and filtering never flash an empty table.
26
+ * paging and filtering never flash an empty table. The Organization closes
27
+ * every key, so each Organization has its own cache (ADR 0014). A list is
28
+ * filtered by the resolved Organization, never by the one an editor page
29
+ * claimed for its resource, which may still be held while the list mounts.
30
+ * `organizationId` lists another Organization instead (the Message Send Flow's).
27
31
  */
28
32
  export function useMessageGroupListQuery(
29
33
  options: MaybeRefOrGetter<ListMessageGroupsOptions>,
30
- queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
34
+ queryOptions: {
35
+ enabled?: MaybeRefOrGetter<boolean>
36
+ organizationId?: MaybeRefOrGetter<string | undefined>
37
+ } = {},
31
38
  ) {
39
+ const organization = useEffectiveOrganizationStore()
40
+ const listOrganizationId = () =>
41
+ toValue(queryOptions.organizationId) || organization.resolvedOrganizationId
42
+
32
43
  return useQuery({
33
- key: () => ['message-groups', JSON.stringify(toValue(options))],
34
- query: () => messageGroupsApi.list(toValue(options)),
44
+ key: () => ['message-groups', JSON.stringify(toValue(options)), listOrganizationId()],
45
+ query: () => {
46
+ // No "all Organizations" list in the layer: never ask without the filter.
47
+ const organization_id = listOrganizationId()
48
+ if (!organization_id) {
49
+ throw new Error(
50
+ '[message-composer] No Organization resolved yet: the list was not requested',
51
+ )
52
+ }
53
+ return messageGroupsApi.list({ ...toValue(options), organization_id })
54
+ },
35
55
  staleTime: 0,
36
- placeholderData: (previousData) => previousData,
56
+ // Never across Organizations: a switch must not show the previous one's rows.
57
+ placeholderData: (previousData, previousEntry) =>
58
+ previousEntry?.key.at(-1) === listOrganizationId() ? previousData : undefined,
37
59
  ...enabledOption(queryOptions.enabled),
38
60
  })
39
61
  }
40
62
 
63
+ /**
64
+ * A Message Group by id. No Organization in the key: the request names none,
65
+ * it acts on the Organization of the Message Group itself (ADR 0014).
66
+ */
41
67
  export function useMessageGroupQuery(
42
68
  id: MaybeRefOrGetter<string>,
43
69
  queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
@@ -1,13 +1,18 @@
1
- import { useMutation, useQueryCache } from '@pinia/colada'
1
+ import { useQuery } from '@pinia/colada'
2
2
  import { metaApi } from '~/api'
3
3
 
4
- export function useSubmitToMetaMutation() {
5
- const queryCache = useQueryCache()
4
+ /**
5
+ * The WABAs of the Effective Organization (ADR 0015). Asked only when the
6
+ * Host's channels include WhatsApp and an Organization is resolved.
7
+ */
8
+ export function useOrganizationWabasQuery() {
9
+ const organization = useEffectiveOrganizationStore()
10
+ const { hasWhatsapp } = useChannels()
6
11
 
7
- return useMutation({
8
- mutation: metaApi.submitGroup,
9
- onSuccess: (_, messageGroupId) => {
10
- queryCache.invalidateQueries({ key: ['message-groups', messageGroupId] })
11
- },
12
+ return useQuery({
13
+ key: () => ['organization-wabas', organization.organizationId],
14
+ query: () => metaApi.getWabas(organization.requireOrganizationId()),
15
+ enabled: () => hasWhatsapp.value && !!organization.organizationId,
16
+ staleTime: 5 * 60 * 1000,
12
17
  })
13
18
  }
@@ -1,36 +1,30 @@
1
- import { ref, computed, watch } from 'vue'
1
+ import { computed, watch } from 'vue'
2
2
  import {
3
3
  MESSAGE_GROUP_PRODUCTS,
4
4
  type AppContextConfig,
5
- type AppContextState,
6
5
  } from '@dev.smartpricing/message-composer-utils/types'
7
6
 
8
7
  export const useAppContextStore = defineStore('appContext', () => {
9
8
  const ctx = useComposerContext()
10
9
 
11
- const isInitialized = ref<boolean>(false)
12
-
13
- // Proxy from injection with defaults (same interface as before)
10
+ // Proxy from injection with defaults
14
11
  const organization_id = computed(() => ctx.organization_id?.value ?? '')
12
+ // A Host that passes the key owns the choice of Organization, even while
13
+ // the value is still empty (ADR 0014). Fixed for the session, like the key.
14
+ const hostOwnsOrganization = ctx.organization_id !== undefined
15
15
  // The Product is required by the context type (ADR 0011), but a host can
16
16
  // still omit it at runtime: read defensively and list it in
17
17
  // `validationErrors` rather than crashing or falling back to a fake Product.
18
18
  const product = computed<AppContextConfig['product'] | undefined>(() => ctx.product?.value)
19
- const strategy = computed<NonNullable<AppContextConfig['strategy']>>(
20
- () => ctx.strategy?.value ?? 'native',
21
- )
22
19
  const channels = computed<NonNullable<AppContextConfig['channels']>>(
23
20
  () => ctx.channels?.value ?? ['email', 'whatsapp'],
24
21
  )
25
22
  const languages = computed<string[]>(() => ctx.languages?.value ?? ['en', 'de', 'fr', 'it', 'es'])
26
23
  const mainLanguage = computed(() => ctx.mainLanguage?.value ?? 'en')
27
- const dinamicValues = computed<Array<{ value: string; label: string }>>(
28
- () => ctx.dinamicValues?.value ?? [],
29
- )
30
24
  const default_tags = computed<string[]>(() => ctx.default_tags?.value ?? [])
31
25
  const isAdmin = computed(() => ctx.isAdmin?.value ?? false)
32
- const metaWabaId = computed(() => ctx.metaWabaId?.value ?? '')
33
- const metaAccessToken = computed(() => ctx.metaAccessToken?.value ?? '')
26
+ // The Host's preselection among the Organization's WABAs (ADR 0015).
27
+ const metaWabaId = computed(() => ctx.metaWabaId?.value || undefined)
34
28
 
35
29
  // Sync i18n locale when mainLanguage changes
36
30
  watch(
@@ -43,20 +37,6 @@ export const useAppContextStore = defineStore('appContext', () => {
43
37
  { immediate: true },
44
38
  )
45
39
 
46
- // Computed context object for convenience
47
- const context = computed<AppContextState>(() => ({
48
- product: product.value,
49
- strategy: strategy.value,
50
- channels: channels.value,
51
- languages: languages.value,
52
- mainLanguage: mainLanguage.value,
53
- metaWabaId: metaWabaId.value,
54
- metaAccessToken: metaAccessToken.value,
55
- organization_id: organization_id.value,
56
- default_tags: default_tags.value,
57
- initializationSource: 'injection',
58
- }))
59
-
60
40
  // Validation
61
41
  const validationErrors = computed<Record<string, string>>(() => {
62
42
  const errors: Record<string, string> = {}
@@ -65,36 +45,20 @@ export const useAppContextStore = defineStore('appContext', () => {
65
45
  errors.product = `Product is required: pass \`product\` (${MESSAGE_GROUP_PRODUCTS.join(' | ')}) in the composer context`
66
46
  }
67
47
 
68
- if (channels.value?.includes('whatsapp') && !metaWabaId.value) {
69
- errors.metaConfig = 'Meta WABA ID is required for WhatsApp'
70
- }
71
-
72
48
  return errors
73
49
  })
74
50
 
75
- // Initialize — parent is responsible for providing valid context
76
- async function initialize() {
77
- isInitialized.value = true
78
- }
79
-
80
51
  return {
81
52
  product,
82
- strategy,
83
53
  channels,
84
54
  languages,
85
55
  mainLanguage,
86
- dinamicValues,
87
- metaWabaId,
88
- metaAccessToken,
89
56
  organization_id,
57
+ hostOwnsOrganization,
90
58
  default_tags,
91
59
  isAdmin,
60
+ metaWabaId,
92
61
 
93
- isInitialized: computed(() => isInitialized.value),
94
-
95
- context,
96
62
  validationErrors,
97
-
98
- initialize,
99
63
  }
100
64
  })
@@ -0,0 +1,128 @@
1
+ import type { ReachableOrganization } from '@dev.smartpricing/message-composer-utils/types'
2
+
3
+ /** Local storage key of the Organization last picked in the selector. */
4
+ const REMEMBERED_ORGANIZATION_KEY = 'message-composer:organization'
5
+
6
+ /**
7
+ * - `resolving`: the Reachable Organizations lookup is running, or the Host
8
+ * that owns the choice has not supplied its Organization yet
9
+ * - `resolved`: there is an Organization to work on
10
+ * - `failed`: the lookup failed, `retry()` runs it again
11
+ * - `none`: the user reaches no Organization
12
+ */
13
+ export type OrganizationResolutionStatus = 'resolving' | 'resolved' | 'failed' | 'none'
14
+
15
+ /**
16
+ * The Effective Organization (ADR 0014): the Organization of the resource
17
+ * being viewed or edited while a page claims one, otherwise the resolved one.
18
+ * A Host that passes `organization_id` owns the choice: the resolved one is
19
+ * its value, and nothing is resolved until that value arrives. Otherwise it
20
+ * is the user's only Reachable Organization; else the remembered selection if
21
+ * still reachable; else the first Reachable Organization.
22
+ */
23
+ export const useEffectiveOrganizationStore = defineStore('effectiveOrganization', () => {
24
+ const appContext = useAppContextStore()
25
+ const { organization_id: hostOrganizationId } = storeToRefs(appContext)
26
+ const { hostOwnsOrganization } = appContext
27
+ const remembered = useLocalStorage(REMEMBERED_ORGANIZATION_KEY, '')
28
+
29
+ /** Undefined until the lookup has answered. */
30
+ const reachable = shallowRef<ReachableOrganization[]>()
31
+ const lookupState = ref<'idle' | 'loading' | 'failed'>('idle')
32
+
33
+ async function lookUp() {
34
+ if (lookupState.value === 'loading') return
35
+ lookupState.value = 'loading'
36
+ try {
37
+ // Imported on demand: the API client reads the runtime config when its
38
+ // module loads, and this store is reachable from plugins that load
39
+ // before the Nuxt app exists (same reason as in `useTracking`).
40
+ const { organizationApi } = await import('~/api/organization')
41
+ reachable.value = await organizationApi.getReachable()
42
+ lookupState.value = 'idle'
43
+ } catch {
44
+ lookupState.value = 'failed'
45
+ }
46
+ }
47
+
48
+ // A Host that supplies the Organization needs no lookup, however late its
49
+ // value arrives: falling back to a Reachable Organization meanwhile would
50
+ // send requests to one the Host did not mean (PAY-2882). Plain state rather
51
+ // than a query: the store outlives whichever component first asks for it.
52
+ if (!hostOwnsOrganization) void lookUp()
53
+
54
+ const reachableOrganizations = computed(() => reachable.value ?? [])
55
+
56
+ const resolvedOrganizationId = computed<string | null>(() => {
57
+ if (hostOwnsOrganization) return hostOrganizationId.value || null
58
+
59
+ const organizations = reachableOrganizations.value
60
+ // Reading `remembered` here is what validates it on load: a selection that
61
+ // is no longer reachable is ignored.
62
+ const selected =
63
+ organizations.length > 1 ? organizations.find((o) => o.id === remembered.value) : undefined
64
+ return (selected ?? organizations[0])?.id ?? null
65
+ })
66
+
67
+ /** The Organization of the resource on screen, and the component that claimed it. */
68
+ const resourceClaim = shallowRef<{ claimant: symbol; organizationId: string }>()
69
+
70
+ const organizationId = computed<string | null>(
71
+ () => resourceClaim.value?.organizationId ?? resolvedOrganizationId.value,
72
+ )
73
+
74
+ /**
75
+ * The Effective Organization for a request that has to name it (ADR 0014).
76
+ * Throws while none is resolved, so a resource request never leaves without
77
+ * its Organization (PAY-2882): callers wait for it to be resolved.
78
+ */
79
+ function requireOrganizationId(): string {
80
+ if (!organizationId.value) {
81
+ throw new Error('[message-composer] No Organization resolved yet: the request was not sent')
82
+ }
83
+ return organizationId.value
84
+ }
85
+
86
+ /** See `useResourceOrganization`: pages do not call this directly. */
87
+ function claimResource(claimant: symbol, id: string | null | undefined) {
88
+ if (id) resourceClaim.value = { claimant, organizationId: id }
89
+ else releaseResource(claimant)
90
+ }
91
+
92
+ // Only the claimant releases: a page that mounts before the previous one is
93
+ // gone must not lose its claim to the other's cleanup.
94
+ function releaseResource(claimant: symbol) {
95
+ if (resourceClaim.value?.claimant === claimant) resourceClaim.value = undefined
96
+ }
97
+
98
+ const status = computed<OrganizationResolutionStatus>(() => {
99
+ if (organizationId.value) return 'resolved'
100
+ if (lookupState.value === 'failed') return 'failed'
101
+ if (lookupState.value === 'idle' && reachable.value) return 'none'
102
+ return 'resolving'
103
+ })
104
+
105
+ /** The selector is offered only when the choice is the user's to make. */
106
+ const isSelectable = computed(
107
+ () => !hostOwnsOrganization && reachableOrganizations.value.length > 1,
108
+ )
109
+
110
+ function select(id: string) {
111
+ if (reachableOrganizations.value.some((o) => o.id === id)) {
112
+ remembered.value = id
113
+ }
114
+ }
115
+
116
+ return {
117
+ organizationId,
118
+ requireOrganizationId,
119
+ resolvedOrganizationId,
120
+ reachableOrganizations,
121
+ status,
122
+ isSelectable,
123
+ select,
124
+ retry: lookUp,
125
+ claimResource,
126
+ releaseResource,
127
+ }
128
+ })
@@ -199,7 +199,11 @@ export const useEmailEditorStore = defineStore('emailEditor', () => {
199
199
  sort_by?: 'created_at' | 'name'
200
200
  direction?: 'asc' | 'desc'
201
201
  }) {
202
- brands.value = await brandsApi.getAll(options)
202
+ // The Brands of the Effective Organization: the edited Message Group's.
203
+ brands.value = await brandsApi.getAll({
204
+ ...options,
205
+ organization_id: useEffectiveOrganizationStore().requireOrganizationId(),
206
+ })
203
207
  return brands.value
204
208
  }
205
209
 
@@ -1,7 +1,6 @@
1
1
  import type { SubmissionJob } from '@dev.smartpricing/message-composer-utils/types'
2
2
  import { metaApi } from '~/api/meta'
3
3
  import { useQueryCache } from '@pinia/colada'
4
- import { useAppContextStore } from './appContext'
5
4
 
6
5
  interface ActiveJob {
7
6
  jobId: string
@@ -12,21 +11,17 @@ interface ActiveJob {
12
11
  export const useMetaSubmissionStore = () => {
13
12
  const toast = useToast()
14
13
  const queryCache = useQueryCache()
15
- const appContext = useAppContextStore()
16
14
  const { t } = useI18n()
17
15
 
18
16
  const activeJobs = useState<Map<string, ActiveJob>>('meta-submission-jobs', () => new Map())
19
17
  const pollingInterval = useState<NodeJS.Timeout | null>('meta-polling-interval', () => null)
20
18
 
21
- const isConfigured = computed(() => !!appContext.metaWabaId)
22
-
23
- const submitToMeta = async (messageGroupId: string) => {
24
- if (!isConfigured.value) {
25
- throw new Error('Meta configuration not available')
26
- }
27
-
19
+ // `wabaId` is the WABA the user picked for a first submission, if any.
20
+ const submitToMeta = async (messageGroupId: string, wabaId?: string) => {
28
21
  try {
29
- const response = await metaApi.submitGroup(messageGroupId)
22
+ const response = await metaApi.submitGroup(messageGroupId, wabaId)
23
+ // The Message Group is on its WABA from now on: read it again.
24
+ queryCache.invalidateQueries({ key: ['message-groups', messageGroupId] })
30
25
  startBackgroundPolling(response.jobId, messageGroupId)
31
26
  return response
32
27
  } catch (error) {
@@ -38,13 +33,9 @@ export const useMetaSubmissionStore = () => {
38
33
  // Submit a single message (one language) for Meta review. Synchronous:
39
34
  // the endpoint submits inline and flips the message to `pending`, so there
40
35
  // is no job to poll — unlike the group flow.
41
- const submitMessageToMeta = async (messageId: string) => {
42
- if (!isConfigured.value) {
43
- throw new Error('Meta configuration not available')
44
- }
45
-
36
+ const submitMessageToMeta = async (messageId: string, wabaId?: string) => {
46
37
  try {
47
- const response = await metaApi.submitMessage(messageId)
38
+ const response = await metaApi.submitMessage(messageId, wabaId)
48
39
  toast.add({
49
40
  color: 'success',
50
41
  title: t('meta_submission.message_success'),
@@ -133,7 +124,6 @@ export const useMetaSubmissionStore = () => {
133
124
  return {
134
125
  activeJobs,
135
126
  hasActiveJobs,
136
- isConfigured,
137
127
  submitToMeta,
138
128
  submitMessageToMeta,
139
129
  startBackgroundPolling,
@@ -51,6 +51,17 @@ export default {
51
51
  },
52
52
  internal: 'Intern',
53
53
  mobile: 'Mobil',
54
+ organization: {
55
+ label: 'Organisation',
56
+ lookup_failed_description:
57
+ 'Beim Laden der Organisationen, auf die Sie Zugriff haben, ist ein Fehler aufgetreten. Versuchen Sie es erneut.',
58
+ lookup_failed_title: 'Ihre Organisationen konnten nicht geladen werden',
59
+ none_description: 'Ihr Benutzer hat auf keine Organisation Zugriff.',
60
+ none_title: 'Keine Organisation verfügbar',
61
+ resolving: 'Ihre Organisationen werden geladen...',
62
+ retry: 'Erneut versuchen',
63
+ search_placeholder: 'Organisationen suchen...',
64
+ },
54
65
  preview: 'Vorschau',
55
66
  search: 'Suchen...',
56
67
  success: 'Erfolg',
@@ -65,6 +76,14 @@ export default {
65
76
  },
66
77
  view_less: 'Weniger anzeigen',
67
78
  view_more: 'Mehr anzeigen',
79
+ whatsapp_not_configured_description:
80
+ 'Diese Organisation hat kein WhatsApp-Business-Konto. WhatsApp-Nachrichten können als Entwurf gespeichert, aber nicht zur Genehmigung eingereicht werden.',
81
+ whatsapp_not_configured_title: 'WhatsApp ist nicht konfiguriert',
82
+ whatsapp_waba_detached_description:
83
+ 'Sie kann nicht mehr zur Genehmigung eingereicht werden. Dupliziere sie, um die Kopie bei einem anderen WhatsApp Business-Konto einzureichen.',
84
+ whatsapp_waba_detached_title:
85
+ 'Diese Nachricht gehört zu einem WhatsApp Business-Konto, das nicht mehr verbunden ist',
86
+ whatsapp_waba_label: 'WhatsApp Business-Konto:',
68
87
  },
69
88
  dialogs: {
70
89
  delete_message: {
@@ -111,6 +130,13 @@ export default {
111
130
  'Die {language}-Übersetzung zur Überprüfung einreichen? Sie können sie nicht bearbeiten, solange sie auf Genehmigung wartet.',
112
131
  title: 'Überprüfung anfordern',
113
132
  },
133
+ select_waba: {
134
+ confirm: 'Genehmigung anfordern',
135
+ description:
136
+ 'Diese Organisation hat mehr als ein WhatsApp Business-Konto. Wähle das Konto, bei dem diese Nachricht eingereicht wird: Es kann danach nicht mehr geändert werden.',
137
+ no_numbers: 'Keine verbundene Nummer',
138
+ title: 'WhatsApp Business-Konto auswählen',
139
+ },
114
140
  unpublish_message_group: {
115
141
  message:
116
142
  'Veröffentlichung von „{name}“ aufheben? Es ist dann nicht mehr für Kundinnen und Kunden sichtbar.',
@@ -867,6 +893,8 @@ export default {
867
893
  COMPILE_FAILED:
868
894
  'Die Nachricht konnte nicht vorbereitet werden. Bitte versuchen Sie es erneut.',
869
895
  MISSING_PARAMETERS: 'Füllen Sie alle Parameter aus, bevor Sie bestätigen.',
896
+ NOT_OFFERED:
897
+ '„{name}“ kann von hier aus nicht gesendet werden. Wählen Sie eine andere Nachrichtengruppe.',
870
898
  NOT_SENDABLE:
871
899
  '„{name}“ hat keine genehmigte Nachricht und kann nicht gesendet werden. Wählen Sie eine andere Nachrichtengruppe.',
872
900
  TEMPLATE_NOT_APPROVED:
@@ -50,6 +50,17 @@ export default {
50
50
  },
51
51
  internal: 'Internal',
52
52
  mobile: 'Mobile',
53
+ organization: {
54
+ label: 'Organization',
55
+ lookup_failed_description:
56
+ 'Something went wrong while loading the organizations you can access. Try again.',
57
+ lookup_failed_title: 'Your organizations could not be loaded',
58
+ none_description: 'Your user has no access to any organization.',
59
+ none_title: 'No organization available',
60
+ resolving: 'Loading your organizations...',
61
+ retry: 'Try again',
62
+ search_placeholder: 'Search organizations...',
63
+ },
53
64
  preview: 'Preview',
54
65
  search: 'Search...',
55
66
  success: 'Success',
@@ -63,6 +74,14 @@ export default {
63
74
  },
64
75
  view_less: 'View less',
65
76
  view_more: 'View more',
77
+ whatsapp_not_configured_description:
78
+ 'This organization has no WhatsApp Business Account. WhatsApp messages can be saved as drafts, but not submitted for approval.',
79
+ whatsapp_not_configured_title: 'WhatsApp is not configured',
80
+ whatsapp_waba_detached_description:
81
+ 'It can no longer be submitted for approval. Duplicate it to submit the copy to another WhatsApp Business Account.',
82
+ whatsapp_waba_detached_title:
83
+ 'This message is on a WhatsApp Business Account that is no longer connected',
84
+ whatsapp_waba_label: 'WhatsApp Business Account:',
66
85
  },
67
86
  dialogs: {
68
87
  delete_message: {
@@ -107,6 +126,13 @@ export default {
107
126
  "Submit the {language} translation for review? You won't be able to edit it while it's pending approval.",
108
127
  title: 'Request review',
109
128
  },
129
+ select_waba: {
130
+ confirm: 'Request approval',
131
+ description:
132
+ 'This organization has more than one WhatsApp Business Account. Choose the one this message is submitted to: it cannot be changed afterwards.',
133
+ no_numbers: 'No connected number',
134
+ title: 'Choose the WhatsApp Business Account',
135
+ },
110
136
  unpublish_message_group: {
111
137
  message: 'Unpublish "{name}"? It will no longer be visible to clients.',
112
138
  success: '"{name}" has been unpublished.',
@@ -855,6 +881,7 @@ export default {
855
881
  errors: {
856
882
  COMPILE_FAILED: 'The message could not be prepared. Please try again.',
857
883
  MISSING_PARAMETERS: 'Fill in every parameter before confirming.',
884
+ NOT_OFFERED: '"{name}" cannot be sent from here. Pick another message group.',
858
885
  NOT_SENDABLE:
859
886
  '"{name}" has no approved message and cannot be sent. Pick another message group.',
860
887
  TEMPLATE_NOT_APPROVED: 'This message has not been approved by Meta and cannot be sent.',