@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.
- package/README.md +228 -46
- package/app/api/brands.ts +20 -13
- package/app/api/client.ts +15 -6
- package/app/api/compilation.ts +13 -3
- package/app/api/dynamicValues.ts +6 -2
- package/app/api/media.ts +6 -3
- package/app/api/messageGroups.ts +29 -14
- 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 +30 -0
- package/app/components/Common/HtmlPreviewIframe.vue +17 -3
- package/app/components/Common/MessageGroupSelect.vue +164 -26
- package/app/components/Common/MessagePreviewSlideover.vue +3 -0
- 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/MessageLibrary.vue +17 -7
- package/app/components/Composer/WabaSelectModal.vue +75 -0
- package/app/components/Composer/WhatsappComposer.vue +78 -3
- package/app/components/MessageSendFlow/EmailPreview.vue +81 -0
- package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +265 -0
- package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
- package/app/components/TemplateList/BaseTable.vue +156 -108
- package/app/components/Whatsapp/Preview.vue +12 -4
- package/app/components/Whatsapp/PreviewPanel.vue +7 -1
- package/app/composables/composerContext.ts +9 -7
- package/app/composables/useEffectiveOrganization.ts +34 -0
- package/app/composables/useMessageGroupList.ts +186 -0
- package/app/composables/useMessageGroupWaba.ts +73 -0
- package/app/composables/useMessageSendFlow.ts +284 -0
- package/app/composables/usePagedMessageGroups.ts +78 -0
- package/app/composables/useTemplateOperations.ts +23 -4
- package/app/composables/useTracking.ts +14 -5
- package/app/composables/useWhatsappConfiguration.ts +32 -0
- package/app/layouts/default.vue +5 -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 +16 -3
- package/app/queries/dynamicValues.ts +19 -2
- package/app/queries/media.ts +8 -3
- package/app/queries/messageGroups.ts +56 -17
- package/app/queries/meta.ts +13 -8
- package/app/stores/appContext.ts +18 -48
- package/app/stores/effectiveOrganization.ts +128 -0
- package/app/stores/emailEditor.ts +5 -1
- package/app/stores/metaSubmission.ts +7 -17
- package/app/types/tracking.ts +17 -0
- package/app/utils/testIds.const.ts +29 -0
- package/i18n/locales/de.ts +84 -2
- package/i18n/locales/en.ts +80 -2
- package/i18n/locales/es.ts +81 -2
- package/i18n/locales/fr.ts +81 -2
- package/i18n/locales/it.ts +82 -2
- package/package.json +2 -2
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
1
2
|
import type { TrackingEventMap } from '~/types/tracking'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
/** `organizationId` names the Organization of the events instead of the Effective one. */
|
|
5
|
+
export function useTracking(
|
|
6
|
+
options: { organizationId?: MaybeRefOrGetter<string | undefined> } = {},
|
|
7
|
+
) {
|
|
4
8
|
const { product, mainLanguage, isAdmin } = storeToRefs(useAppContextStore())
|
|
9
|
+
const effectiveOrganization = useEffectiveOrganizationStore()
|
|
10
|
+
const organizationId = computed(
|
|
11
|
+
() => toValue(options.organizationId) || effectiveOrganization.organizationId,
|
|
12
|
+
)
|
|
5
13
|
|
|
6
14
|
function getDefaultProperties(): Record<string, unknown> {
|
|
7
15
|
return {
|
|
@@ -17,12 +25,13 @@ export function useTracking() {
|
|
|
17
25
|
properties?: TrackingEventMap[T],
|
|
18
26
|
): Promise<void> {
|
|
19
27
|
if (!useAppConfig().composer?.tracking) return
|
|
28
|
+
const payload = { ...getDefaultProperties(), ...properties }
|
|
20
29
|
try {
|
|
30
|
+
// The tracking route needs an Organization: an event fired while it is
|
|
31
|
+
// still being resolved (the first page view) waits instead of being lost.
|
|
32
|
+
const organization = await until(organizationId).toBeTruthy()
|
|
21
33
|
const { trackingApi } = await import('@/api/tracking')
|
|
22
|
-
await trackingApi.event(eventName,
|
|
23
|
-
...getDefaultProperties(),
|
|
24
|
-
...properties,
|
|
25
|
-
})
|
|
34
|
+
await trackingApi.event(eventName, organization, payload)
|
|
26
35
|
} catch {
|
|
27
36
|
if (import.meta.dev) {
|
|
28
37
|
console.warn(`[Tracking] Failed to track "${eventName}"`, properties)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* - `unavailable`: the Host's channels do not include WhatsApp
|
|
3
|
+
* - `checking`: the WABA lookup of the Effective Organization is running
|
|
4
|
+
* - `configured`: the Effective Organization has at least one WABA
|
|
5
|
+
* - `not_configured`: the lookup answered, the Organization has no WABA
|
|
6
|
+
* - `failed`: the lookup failed, so whether there is a WABA is not known
|
|
7
|
+
*/
|
|
8
|
+
export type WhatsappConfigurationStatus =
|
|
9
|
+
| 'unavailable'
|
|
10
|
+
| 'checking'
|
|
11
|
+
| 'configured'
|
|
12
|
+
| 'not_configured'
|
|
13
|
+
| 'failed'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether WhatsApp is configured: the Host's channels include WhatsApp and
|
|
17
|
+
* the Effective Organization has at least one WABA (ADR 0015). The WABAs
|
|
18
|
+
* follow the Organization, so the answer changes with it.
|
|
19
|
+
* A lookup still running or failed is never read as "no WABA".
|
|
20
|
+
*/
|
|
21
|
+
export function useWhatsappConfiguration() {
|
|
22
|
+
const { hasWhatsapp } = useChannels()
|
|
23
|
+
const { data, status: lookupStatus } = useOrganizationWabasQuery()
|
|
24
|
+
|
|
25
|
+
const status = computed<WhatsappConfigurationStatus>(() => {
|
|
26
|
+
if (!hasWhatsapp.value) return 'unavailable'
|
|
27
|
+
if (data.value) return data.value.length > 0 ? 'configured' : 'not_configured'
|
|
28
|
+
return lookupStatus.value === 'error' ? 'failed' : 'checking'
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return { status }
|
|
32
|
+
}
|
package/app/layouts/default.vue
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<SNavigationPage :panel-props="{ ui: { body: 'p-0 sm:p-0 gap-0 sm:gap-0 overflow-hidden' } }">
|
|
3
|
-
<
|
|
3
|
+
<CommonAppContextErrors />
|
|
4
|
+
<CommonWhatsappNotConfigured />
|
|
5
|
+
<CommonOrganizationGate>
|
|
6
|
+
<slot />
|
|
7
|
+
</CommonOrganizationGate>
|
|
4
8
|
</SNavigationPage>
|
|
5
9
|
</template>
|
package/app/pages/brands.vue
CHANGED
package/app/pages/images.vue
CHANGED
|
@@ -8,6 +8,10 @@ const { t } = useI18n()
|
|
|
8
8
|
|
|
9
9
|
<template>
|
|
10
10
|
<LayoutBasePage :title="t('pages.images.title')" :test-id="imagesPageTestIds.navbar">
|
|
11
|
+
<template #actions>
|
|
12
|
+
<CommonOrganizationSelect />
|
|
13
|
+
</template>
|
|
14
|
+
|
|
11
15
|
<ComposerImageGallery />
|
|
12
16
|
</LayoutBasePage>
|
|
13
17
|
</template>
|
package/app/pages/index.vue
CHANGED
|
@@ -13,6 +13,8 @@ definePageMeta({
|
|
|
13
13
|
const route = useRoute()
|
|
14
14
|
const { locale } = useI18n()
|
|
15
15
|
const { data: messageGroup } = useMessageGroupQuery(() => route.params.id as string)
|
|
16
|
+
// The preview works on the Organization of the Message Group it shows.
|
|
17
|
+
useResourceOrganization(() => messageGroup.value?.organization_id)
|
|
16
18
|
|
|
17
19
|
const selectedMessage = computed(() => {
|
|
18
20
|
if (!messageGroup.value?.messages.length) return undefined
|
|
@@ -55,6 +55,7 @@ const store = useWhatsappComposerStore()
|
|
|
55
55
|
:label="$t('pages.whatsapp.create_message_group.request_approval')"
|
|
56
56
|
color="primary"
|
|
57
57
|
:loading="composerRef?.saving || composerRef?.isTranslating"
|
|
58
|
+
:disabled="composerRef?.isWabaDetached"
|
|
58
59
|
:data-testid="templateWhatsappEditTestIds.requestApprovalButton"
|
|
59
60
|
/>
|
|
60
61
|
</template>
|
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
|
|
|
@@ -37,12 +44,18 @@ export function useResolvedParametersQuery(
|
|
|
37
44
|
export function useCompileEmailMutation() {
|
|
38
45
|
return useMutation({
|
|
39
46
|
mutation: ({ messageGroupId, data }: { messageGroupId: string; data: CompileMessageRequest }) =>
|
|
40
|
-
compilationApi.
|
|
47
|
+
compilationApi.compile(messageGroupId, data),
|
|
41
48
|
})
|
|
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
|
}
|
|
@@ -1,13 +1,30 @@
|
|
|
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
|
|
|
5
6
|
export function useDynamicValuesQuery() {
|
|
6
7
|
const { product, mainLanguage } = storeToRefs(useAppContextStore())
|
|
7
8
|
|
|
9
|
+
const organization = useEffectiveOrganizationStore()
|
|
10
|
+
|
|
8
11
|
return useQuery({
|
|
9
|
-
key: () => [
|
|
10
|
-
|
|
12
|
+
key: () => [
|
|
13
|
+
'dynamic-values',
|
|
14
|
+
product.value ?? '',
|
|
15
|
+
mainLanguage.value,
|
|
16
|
+
organization.organizationId,
|
|
17
|
+
],
|
|
18
|
+
// No Product, no request: the picker stays empty and the store lists the
|
|
19
|
+
// missing Product in `validationErrors`.
|
|
20
|
+
query: () => {
|
|
21
|
+
if (!product.value) return Promise.resolve<DynamicValuesResponse>({})
|
|
22
|
+
return dynamicValuesApi.get(
|
|
23
|
+
product.value,
|
|
24
|
+
mainLanguage.value || 'en',
|
|
25
|
+
organization.requireOrganizationId(),
|
|
26
|
+
)
|
|
27
|
+
},
|
|
11
28
|
staleTime: 30 * 60 * 1000,
|
|
12
29
|
})
|
|
13
30
|
}
|
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
|
|
|
@@ -1,39 +1,78 @@
|
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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. 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).
|
|
31
|
+
*/
|
|
32
|
+
export function useMessageGroupListQuery(
|
|
33
|
+
options: MaybeRefOrGetter<ListMessageGroupsOptions>,
|
|
34
|
+
queryOptions: {
|
|
35
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
36
|
+
organizationId?: MaybeRefOrGetter<string | undefined>
|
|
37
|
+
} = {},
|
|
19
38
|
) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
const organization = useEffectiveOrganizationStore()
|
|
40
|
+
const listOrganizationId = () =>
|
|
41
|
+
toValue(queryOptions.organizationId) || organization.resolvedOrganizationId
|
|
42
|
+
|
|
43
|
+
return useQuery({
|
|
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
|
+
},
|
|
23
55
|
staleTime: 0,
|
|
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,
|
|
59
|
+
...enabledOption(queryOptions.enabled),
|
|
24
60
|
})
|
|
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
61
|
}
|
|
31
62
|
|
|
32
|
-
|
|
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
|
+
*/
|
|
67
|
+
export function useMessageGroupQuery(
|
|
68
|
+
id: MaybeRefOrGetter<string>,
|
|
69
|
+
queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
|
|
70
|
+
) {
|
|
33
71
|
return useQuery({
|
|
34
72
|
key: () => ['message-groups', toValue(id)],
|
|
35
73
|
query: () => messageGroupsApi.getById(toValue(id)),
|
|
36
74
|
staleTime: 2 * 60 * 1000,
|
|
75
|
+
...enabledOption(queryOptions.enabled),
|
|
37
76
|
})
|
|
38
77
|
}
|
|
39
78
|
|
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,34 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { computed, watch } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
MESSAGE_GROUP_PRODUCTS,
|
|
4
|
+
type AppContextConfig,
|
|
5
5
|
} from '@dev.smartpricing/message-composer-utils/types'
|
|
6
6
|
|
|
7
7
|
export const useAppContextStore = defineStore('appContext', () => {
|
|
8
8
|
const ctx = useComposerContext()
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// Proxy from injection with defaults (same interface as before)
|
|
10
|
+
// Proxy from injection with defaults
|
|
13
11
|
const organization_id = computed(() => ctx.organization_id?.value ?? '')
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
// 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)
|
|
20
19
|
const channels = computed<NonNullable<AppContextConfig['channels']>>(
|
|
21
20
|
() => ctx.channels?.value ?? ['email', 'whatsapp'],
|
|
22
21
|
)
|
|
23
22
|
const languages = computed<string[]>(() => ctx.languages?.value ?? ['en', 'de', 'fr', 'it', 'es'])
|
|
24
23
|
const mainLanguage = computed(() => ctx.mainLanguage?.value ?? 'en')
|
|
25
|
-
const dinamicValues = computed<Array<{ value: string; label: string }>>(
|
|
26
|
-
() => ctx.dinamicValues?.value ?? [],
|
|
27
|
-
)
|
|
28
24
|
const default_tags = computed<string[]>(() => ctx.default_tags?.value ?? [])
|
|
29
25
|
const isAdmin = computed(() => ctx.isAdmin?.value ?? false)
|
|
30
|
-
|
|
31
|
-
const
|
|
26
|
+
// The Host's preselection among the Organization's WABAs (ADR 0015).
|
|
27
|
+
const metaWabaId = computed(() => ctx.metaWabaId?.value || undefined)
|
|
32
28
|
|
|
33
29
|
// Sync i18n locale when mainLanguage changes
|
|
34
30
|
watch(
|
|
@@ -41,54 +37,28 @@ export const useAppContextStore = defineStore('appContext', () => {
|
|
|
41
37
|
{ immediate: true },
|
|
42
38
|
)
|
|
43
39
|
|
|
44
|
-
// Computed context object for convenience
|
|
45
|
-
const context = computed<AppContextState>(() => ({
|
|
46
|
-
product: product.value,
|
|
47
|
-
strategy: strategy.value,
|
|
48
|
-
channels: channels.value,
|
|
49
|
-
languages: languages.value,
|
|
50
|
-
mainLanguage: mainLanguage.value,
|
|
51
|
-
metaWabaId: metaWabaId.value,
|
|
52
|
-
metaAccessToken: metaAccessToken.value,
|
|
53
|
-
organization_id: organization_id.value,
|
|
54
|
-
default_tags: default_tags.value,
|
|
55
|
-
initializationSource: 'injection',
|
|
56
|
-
}))
|
|
57
|
-
|
|
58
40
|
// Validation
|
|
59
41
|
const validationErrors = computed<Record<string, string>>(() => {
|
|
60
42
|
const errors: Record<string, string> = {}
|
|
61
43
|
|
|
62
|
-
if (
|
|
63
|
-
errors.
|
|
44
|
+
if (!product.value) {
|
|
45
|
+
errors.product = `Product is required: pass \`product\` (${MESSAGE_GROUP_PRODUCTS.join(' | ')}) in the composer context`
|
|
64
46
|
}
|
|
65
47
|
|
|
66
48
|
return errors
|
|
67
49
|
})
|
|
68
50
|
|
|
69
|
-
// Initialize — parent is responsible for providing valid context
|
|
70
|
-
async function initialize() {
|
|
71
|
-
isInitialized.value = true
|
|
72
|
-
}
|
|
73
|
-
|
|
74
51
|
return {
|
|
75
52
|
product,
|
|
76
|
-
strategy,
|
|
77
53
|
channels,
|
|
78
54
|
languages,
|
|
79
55
|
mainLanguage,
|
|
80
|
-
dinamicValues,
|
|
81
|
-
metaWabaId,
|
|
82
|
-
metaAccessToken,
|
|
83
56
|
organization_id,
|
|
57
|
+
hostOwnsOrganization,
|
|
84
58
|
default_tags,
|
|
85
59
|
isAdmin,
|
|
60
|
+
metaWabaId,
|
|
86
61
|
|
|
87
|
-
isInitialized: computed(() => isInitialized.value),
|
|
88
|
-
|
|
89
|
-
context,
|
|
90
62
|
validationErrors,
|
|
91
|
-
|
|
92
|
-
initialize,
|
|
93
63
|
}
|
|
94
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
|
+
})
|