@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.
- package/README.md +51 -5
- package/app/api/brands.ts +20 -13
- package/app/api/client.ts +3 -6
- package/app/api/dynamicValues.ts +2 -1
- package/app/api/media.ts +6 -3
- package/app/api/messageGroups.ts +6 -2
- package/app/api/meta.ts +26 -4
- package/app/api/organization.ts +10 -1
- package/app/api/tracking.ts +2 -2
- package/app/components/Common/AppContextErrors.vue +2 -2
- package/app/components/Common/MessageGroupSelect.vue +13 -2
- package/app/components/Common/OrganizationGate.vue +51 -0
- package/app/components/Common/OrganizationSelect.vue +27 -0
- package/app/components/Common/WhatsappNotConfigured.vue +20 -0
- package/app/components/Composer/EmailComposer.vue +4 -0
- package/app/components/Composer/WabaSelectModal.vue +75 -0
- package/app/components/Composer/WhatsappComposer.vue +78 -3
- package/app/components/MessageSendFlow/EmailPreview.vue +3 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +15 -2
- package/app/composables/composerContext.ts +5 -5
- package/app/composables/useEffectiveOrganization.ts +34 -0
- package/app/composables/useMessageGroupWaba.ts +73 -0
- package/app/composables/useMessageSendFlow.ts +46 -8
- package/app/composables/usePagedMessageGroups.ts +14 -2
- package/app/composables/useTemplateOperations.ts +10 -1
- package/app/composables/useTracking.ts +14 -5
- package/app/composables/useWhatsappConfiguration.ts +32 -0
- package/app/layouts/default.vue +4 -1
- package/app/pages/brands.vue +1 -0
- package/app/pages/images.vue +4 -0
- package/app/pages/index.vue +1 -0
- package/app/pages/preview/[id].vue +2 -0
- package/app/pages/template/whatsapp/[id].vue +1 -0
- package/app/queries/brands.ts +27 -7
- package/app/queries/compilation.ts +15 -2
- package/app/queries/dynamicValues.ts +13 -2
- package/app/queries/media.ts +8 -3
- package/app/queries/messageGroups.ts +31 -5
- package/app/queries/meta.ts +13 -8
- package/app/stores/appContext.ts +9 -45
- package/app/stores/effectiveOrganization.ts +128 -0
- package/app/stores/emailEditor.ts +5 -1
- package/app/stores/metaSubmission.ts +7 -17
- package/i18n/locales/de.ts +28 -0
- package/i18n/locales/en.ts +27 -0
- package/i18n/locales/es.ts +27 -0
- package/i18n/locales/fr.ts +28 -0
- package/i18n/locales/it.ts +27 -0
- package/package.json +2 -2
package/app/queries/brands.ts
CHANGED
|
@@ -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: () => [
|
|
15
|
-
|
|
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) =>
|
|
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:
|
|
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:
|
|
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) =>
|
|
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: () => [
|
|
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(
|
|
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
|
})
|
package/app/queries/media.ts
CHANGED
|
@@ -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: {
|
|
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: () =>
|
|
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
|
-
|
|
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> } = {},
|
package/app/queries/meta.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useQuery } from '@pinia/colada'
|
|
2
2
|
import { metaApi } from '~/api'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
}
|
package/app/stores/appContext.ts
CHANGED
|
@@ -1,36 +1,30 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/i18n/locales/de.ts
CHANGED
|
@@ -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:
|
package/i18n/locales/en.ts
CHANGED
|
@@ -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.',
|