@dev.smartpricing/message-composer-layer 4.4.1-next.0 → 4.5.1-next.1
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/Editor/RichTextEditor.vue +14 -2
- package/app/components/MessageSendFlow/EmailPreview.vue +3 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +15 -2
- package/app/components/Whatsapp/Preview.vue +5 -12
- package/app/components/Whatsapp/Translator/Header.vue +4 -19
- 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 +30 -0
- package/i18n/locales/en.ts +29 -0
- package/i18n/locales/es.ts +29 -0
- package/i18n/locales/fr.ts +30 -0
- package/i18n/locales/it.ts +29 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -38,11 +38,9 @@ The layer is published on the private Smartpricing npm registry together with `@
|
|
|
38
38
|
<script setup lang="ts">
|
|
39
39
|
defineComposerContext({
|
|
40
40
|
product: ref('chat'), // 'connect' | 'chat' | 'pms' | 'pricing'
|
|
41
|
-
organization_id: useAuth().organizationId,
|
|
42
41
|
channels: ref(['whatsapp']),
|
|
43
42
|
mainLanguage: ref('it'),
|
|
44
|
-
|
|
45
|
-
metaAccessToken: useAuth().metaToken, // required for the whatsapp channel
|
|
43
|
+
// organization_id: useAuth().organizationId, // only when the Host works on one Organization
|
|
46
44
|
})
|
|
47
45
|
|
|
48
46
|
useNuxtApp().hook('composer:auth-error', () => {
|
|
@@ -53,10 +51,51 @@ The layer is published on the private Smartpricing npm registry together with `@
|
|
|
53
51
|
|
|
54
52
|
Auth is cookie-based through platform-layer. The full `ComposerContext` interface lives in `app/composables/composerContext.ts`; the design and the parent/layer contract are in [`docs/nuxt-layer-design.md`](../../docs/nuxt-layer-design.md).
|
|
55
53
|
|
|
54
|
+
See "Organization and WABA" below for what `organization_id` and `metaWabaId` do.
|
|
55
|
+
|
|
56
56
|
4. Tracking is opt-in: set `composer.tracking: true` in the host's `app.config.ts` to have the layer post its events to `POST /tracking/event`.
|
|
57
57
|
|
|
58
58
|
The layer's pages are mounted at root (`/`, `/template/...`, `/preview/...`, `/brands`, `/images`). Remap them with `pages:extend` if the host already owns those routes.
|
|
59
59
|
|
|
60
|
+
## Organization and WABA
|
|
61
|
+
|
|
62
|
+
No request depends on an ambient Organization: every request names the one it is for (ADR [0014](../../docs/adr/0014-organization-explicit-on-every-request.md)). The Host no longer has to know it.
|
|
63
|
+
|
|
64
|
+
**Organization.** `organization_id` is optional in the context.
|
|
65
|
+
|
|
66
|
+
- **Given**: the layer works on that Organization and shows no selector. Passing the key is what counts, not its value: it may arrive late (a ref filled after the first render), and until then the layer waits (`status` is `resolving`) and sends no resource request, so a Host does not need to hold the composer back until its Tenant is loaded. A Host that passes the key and never fills it leaves the composer waiting.
|
|
67
|
+
- **Omitted** (the key is not passed at all): the layer asks the backend which Organizations the user can reach (`GET /organizations`). With one, it uses it and looks as before. With several, the Message Group, Brands and images pages show an **Organization selector** in their header: it filters what is listed and decides where new resources are created. The choice is remembered in the browser's local storage and checked against the reachable list on load. There is no "all Organizations" view.
|
|
68
|
+
|
|
69
|
+
On an editor or preview page the Organization is always the one of the Message Group on screen, whatever the selector says, so a link to a Message Group of any Organization the user can reach works.
|
|
70
|
+
|
|
71
|
+
**Effective Organization.** `useEffectiveOrganization()` (auto-imported) is the Host's read-only view of the Organization the layer is working on: `organizationId` (`null` until resolved) and `status` (`resolving` | `resolved` | `failed` | `none`). Use it for anything the Host supplies per Organization, such as `organizationData` (the test-email sender addresses and accommodation name):
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const organizationData = ref<OrganizationData>({ senderEmails: [] })
|
|
75
|
+
defineComposerContext({ product: ref('chat'), organizationData })
|
|
76
|
+
|
|
77
|
+
// After `defineComposerContext()`: the composable reads the context.
|
|
78
|
+
const { organizationId } = useEffectiveOrganization()
|
|
79
|
+
watch(
|
|
80
|
+
organizationId,
|
|
81
|
+
async (id) => {
|
|
82
|
+
organizationData.value = id ? await loadSenders(id) : { senderEmails: [] }
|
|
83
|
+
},
|
|
84
|
+
{ immediate: true },
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**WABA.** The Host does not supply the WABA: the WABAs follow the Organization and the backend resolves them (ADR [0015](../../docs/adr/0015-message-group-waba-fixed-at-first-submission.md)). WhatsApp is configured when the Host's `channels` include `whatsapp` and the Organization has at least one WABA. A Message Group is submitted to one WABA, chosen at its first submission and fixed from then on; with several WABAs the user picks one when requesting approval. `metaWabaId` in the context is optional and is only the preselection in that picker.
|
|
89
|
+
|
|
90
|
+
### Upgrading to 4.5.0
|
|
91
|
+
|
|
92
|
+
Nothing is required: a Host that keeps passing `organization_id` and `metaWabaId` behaves as before.
|
|
93
|
+
|
|
94
|
+
- **Removed** from `ComposerContext` and from `AppContextConfig` in utils (no code read them): `authenticationToken`, `strategy`, `metaAccessToken`, `dinamicValues`. Remove them from the `defineComposerContext()` call. The utils types `AppContextState` and `ContextSource` are gone too.
|
|
95
|
+
- **Changed**: `metaWabaId` no longer decides where a WhatsApp submission goes and is no longer required for the WhatsApp channel; it preselects the WABA when the user has to pick one.
|
|
96
|
+
- **Deprecated**, removed in 5.0.0: the `sm-organization-id` and `x-meta-waba-id` request headers. The layer no longer sends them; the backend still accepts them from Hosts on an older layer.
|
|
97
|
+
- **New**: `organization_id` is optional, the Organization selector, `useEffectiveOrganization()`, and the `organizationId` and `wabaId` props of the Message Send Flow.
|
|
98
|
+
|
|
60
99
|
## Message Send Flow
|
|
61
100
|
|
|
62
101
|
`MessageSendFlow` walks an operator from "which Message" to "here is what to send", in two steps inside a container the host owns (Smartchat: a modal):
|
|
@@ -68,6 +107,8 @@ The preview follows every step in a right panel: the picked Message as the guest
|
|
|
68
107
|
|
|
69
108
|
Back and Next move between steps, Cancel is always available, Confirm only on Parameters. The action bar renders under the steps, or wherever `actionsTo` points (a modal footer). The component has no outer padding: the host's container pads it. The component owns no overlay and no send, and it keeps its state after `compiled`: closing the container is the host's decision. There is no reset API; remount it (`:key`) to start over.
|
|
70
109
|
|
|
110
|
+
The flow works on one Organization and never shows a selector: the `organizationId` prop when the Host gives one (Smartchat: the conversation's), otherwise the one the layer resolved, which is also the one the page's selector is on. With `organizationId` the flow lists and compiles that Organization's Sendable Message Groups whatever the rest of the page is working on. With `wabaId` it offers only the WhatsApp Message Groups on the WABA the Host sends from.
|
|
111
|
+
|
|
71
112
|
The `/compile` contract (request, response with `message`, error codes) is documented in [`docs/compile-and-send.md`](../../docs/compile-and-send.md).
|
|
72
113
|
|
|
73
114
|
### Mount
|
|
@@ -90,6 +131,8 @@ function onCompiled(message: SendableMessage) {
|
|
|
90
131
|
<template #body>
|
|
91
132
|
<MessageSendFlow
|
|
92
133
|
v-model:message-group-id="groupId"
|
|
134
|
+
:organization-id="conversation.organizationId"
|
|
135
|
+
:waba-id="conversation.wabaId"
|
|
93
136
|
language="it"
|
|
94
137
|
:parameters="{ guest_name: 'Mario', guest_fullname: 'Mario Rossi' }"
|
|
95
138
|
actions-to="#send-flow-actions"
|
|
@@ -115,6 +158,8 @@ function onCompiled(message: SendableMessage) {
|
|
|
115
158
|
| `language` | `string` | `undefined` | Preferred language (see the language rule). |
|
|
116
159
|
| `parameters` | `Record<string, string>` | `undefined` | Editable prefill of the parameter inputs. Keys the chosen Message does not need are ignored. |
|
|
117
160
|
| `brandId` | `string` | `undefined` | Brand override forwarded to `/compile` as `brand_id`; defaults to the group's brand. |
|
|
161
|
+
| `organizationId` | `string` | `undefined` | The Organization the flow works on. Unset, the one the layer resolved. A preselected group of another Organization raises `NOT_OFFERED`. |
|
|
162
|
+
| `wabaId` | `string` | `undefined` | The WABA the Host sends from: with `whatsapp_v1`, only Message Groups on it are offered. Unset, the groups of every WABA. Ignored for `email_v1`. |
|
|
118
163
|
| `actionsTo` | `string \| HTMLElement` | `undefined` | Where the Cancel/Back/Next/Confirm bar renders, e.g. a `UModal` footer element. Unset, it renders under the steps. |
|
|
119
164
|
|
|
120
165
|
### Emits
|
|
@@ -132,6 +177,7 @@ Types: `SendableMessage` from `@dev.smartpricing/message-composer-utils/types`;
|
|
|
132
177
|
| Code | Raised by | Meaning | `details` |
|
|
133
178
|
| ------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
|
134
179
|
| `NOT_SENDABLE` | layer | The preselected or picked group has no approved Message. The picker stays usable; the alert clears once a Sendable group is picked. | none |
|
|
180
|
+
| `NOT_OFFERED` | layer | The preselected group belongs to another Organization than `organizationId`, or is not on `wabaId`. The picker stays usable. | none |
|
|
135
181
|
| `MISSING_PARAMETERS` | layer | Confirm pressed with an empty required parameter (checked client-side before calling `/compile`). | `{ missing: string[] }` |
|
|
136
182
|
| `MISSING_PARAMETERS` | backend | `/compile` reported a required key as absent. | the backend error body |
|
|
137
183
|
| `TEMPLATE_NOT_SUBMITTED` | backend | The WhatsApp template was never submitted to Meta. | the backend error body |
|
|
@@ -146,7 +192,7 @@ The language selected in step 1 is the `language` prop when that Message is appr
|
|
|
146
192
|
|
|
147
193
|
### Reused blocks and their new props
|
|
148
194
|
|
|
149
|
-
- `MessageGroupSelect` (auto-import `CommonMessageGroupSelect`) gained `eligibility?: 'active' | 'sendable'`. `active` (default, the composer's own picker) selects groups whose every Message is approved; `sendable` selects groups with at least one approved Message. The disabled tooltip and the "hide inactive" filter follow the mode. The send flow uses `sendable`. Details in [`docs/message-group-select-migration.md`](../../docs/message-group-select-migration.md).
|
|
195
|
+
- `MessageGroupSelect` (auto-import `CommonMessageGroupSelect`) gained `eligibility?: 'active' | 'sendable'`. `active` (default, the composer's own picker) selects groups whose every Message is approved; `sendable` selects groups with at least one approved Message. The disabled tooltip and the "hide inactive" filter follow the mode. The send flow uses `sendable`. It also takes `organizationId` (list that Organization instead of the resolved one) and `wabaId` (with `renderType="whatsapp_v1"`, only the groups on that WABA). Details in [`docs/message-group-select-migration.md`](../../docs/message-group-select-migration.md).
|
|
150
196
|
- `WhatsappPreview` gained `parameters?: Record<string, string>`: non-empty values override the organization-level resolved values, keys not in the map keep the resolved mock, an empty string shows the raw `{{key}}` placeholder.
|
|
151
197
|
|
|
152
198
|
### Exported blocks
|
|
@@ -189,7 +235,7 @@ With tracking enabled the flow reports `send_flow.confirm` (`message_name`, `mes
|
|
|
189
235
|
|
|
190
236
|
### Dev harness
|
|
191
237
|
|
|
192
|
-
`apps/frontend` (this monorepo's standalone host) has a permanent `/debug/send-flow` page that plays the host: preselect, preferred language, prefill JSON, brand id, inline or modal container, remount, and a log of every emit.
|
|
238
|
+
`apps/frontend` (this monorepo's standalone host) has a permanent `/debug/send-flow` page that plays the host: preselect, preferred language, prefill JSON, brand id, the flow's own Organization and WABA, inline or modal container, remount, and a log of every emit.
|
|
193
239
|
|
|
194
240
|
## Development
|
|
195
241
|
|
package/app/api/brands.ts
CHANGED
|
@@ -14,19 +14,27 @@ import { apiClient } from './client'
|
|
|
14
14
|
export type BrandWithProperties = BrandFromDb & { properties: BrandProperty[] }
|
|
15
15
|
export type BrandCreateResponse = BrandFromDb & { brand_count: number }
|
|
16
16
|
export type BrandDeleteResponse = { brand_count: number }
|
|
17
|
+
export type BrandPreviewRequest = {
|
|
18
|
+
message: PostEntity<EmailMessage>
|
|
19
|
+
parameters?: Record<string, unknown>
|
|
20
|
+
brand?: BrandCascadeData | undefined
|
|
21
|
+
}
|
|
17
22
|
|
|
18
23
|
export const brandsApi = {
|
|
19
|
-
getAll: (options
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
getAll: (options: {
|
|
25
|
+
organization_id: string
|
|
26
|
+
sort_by?: 'created_at' | 'name'
|
|
27
|
+
direction?: 'asc' | 'desc'
|
|
28
|
+
}) => {
|
|
29
|
+
const params = new URLSearchParams({ organization_id: options.organization_id })
|
|
30
|
+
if (options.sort_by) params.set('sort_by', options.sort_by)
|
|
31
|
+
if (options.direction) params.set('direction', options.direction)
|
|
32
|
+
return apiClient<BrandWithProperties[]>(`/brands?${params.toString()}`)
|
|
25
33
|
},
|
|
26
34
|
|
|
27
35
|
getById: (id: string) => apiClient<BrandWithProperties>(`/brands/${id}`),
|
|
28
36
|
|
|
29
|
-
create: (data: BrandCreateBody) =>
|
|
37
|
+
create: (data: BrandCreateBody & { organization_id: string }) =>
|
|
30
38
|
apiClient<BrandCreateResponse>('/brands', {
|
|
31
39
|
method: 'POST',
|
|
32
40
|
body: data,
|
|
@@ -43,11 +51,7 @@ export const brandsApi = {
|
|
|
43
51
|
|
|
44
52
|
delete: (id: string) => apiClient<BrandDeleteResponse>(`/brands/${id}`, { method: 'DELETE' }),
|
|
45
53
|
|
|
46
|
-
preview: (data: {
|
|
47
|
-
message: PostEntity<EmailMessage>
|
|
48
|
-
parameters?: Record<string, unknown>
|
|
49
|
-
brand?: BrandCascadeData | undefined
|
|
50
|
-
}) =>
|
|
54
|
+
preview: (data: BrandPreviewRequest & { organization_id: string }) =>
|
|
51
55
|
apiClient<CompileEmailResponse>('/message-groups/preview', {
|
|
52
56
|
method: 'POST',
|
|
53
57
|
body: data,
|
|
@@ -55,5 +59,8 @@ export const brandsApi = {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export const propertiesApi = {
|
|
58
|
-
getAll: () =>
|
|
62
|
+
getAll: (organizationId: string) =>
|
|
63
|
+
apiClient<Property[]>(
|
|
64
|
+
`/properties?${new URLSearchParams({ organization_id: organizationId })}`,
|
|
65
|
+
),
|
|
59
66
|
}
|
package/app/api/client.ts
CHANGED
|
@@ -33,15 +33,12 @@ export const apiClient = createApiClient({
|
|
|
33
33
|
useNuxtApp().callHook('composer:auth-error', { statusCode: 401 })
|
|
34
34
|
},
|
|
35
35
|
},
|
|
36
|
+
// No Organization header: every request names its Organization itself, in
|
|
37
|
+
// the query, in the body or through the resource it acts on (ADR 0014).
|
|
38
|
+
// No WABA header either: a submission names its WABA in the body (ADR 0015).
|
|
36
39
|
onRequest({ options }) {
|
|
37
40
|
const store = useAppContextStore()
|
|
38
41
|
|
|
39
|
-
if (store.organization_id) {
|
|
40
|
-
options.headers.set('sm-organization-id', store.organization_id)
|
|
41
|
-
}
|
|
42
|
-
if (store.metaWabaId) {
|
|
43
|
-
options.headers.set('x-meta-waba-id', store.metaWabaId)
|
|
44
|
-
}
|
|
45
42
|
if (store.product) {
|
|
46
43
|
options.headers.set('sm-product', store.product)
|
|
47
44
|
}
|
package/app/api/dynamicValues.ts
CHANGED
|
@@ -5,10 +5,11 @@ import type {
|
|
|
5
5
|
import { apiClient } from './client'
|
|
6
6
|
|
|
7
7
|
export const dynamicValuesApi = {
|
|
8
|
-
get: (product: MessageGroupProduct, language: string) => {
|
|
8
|
+
get: (product: MessageGroupProduct, language: string, organizationId: string) => {
|
|
9
9
|
const params = new URLSearchParams()
|
|
10
10
|
params.set('product', product)
|
|
11
11
|
params.set('language', language)
|
|
12
|
+
params.set('organization_id', organizationId)
|
|
12
13
|
return apiClient<DynamicValuesResponse>(`/dynamic-values?${params.toString()}`)
|
|
13
14
|
},
|
|
14
15
|
}
|
package/app/api/media.ts
CHANGED
|
@@ -2,12 +2,15 @@ import type { MediaFromDb } from '@dev.smartpricing/message-composer-utils/types
|
|
|
2
2
|
import { apiClient } from './client'
|
|
3
3
|
|
|
4
4
|
export const mediaApi = {
|
|
5
|
-
list: () =>
|
|
5
|
+
list: (organizationId: string) =>
|
|
6
|
+
apiClient<MediaFromDb[]>(`/media?${new URLSearchParams({ organization_id: organizationId })}`),
|
|
6
7
|
|
|
7
|
-
upload: (file: File) => {
|
|
8
|
+
upload: (file: File, organizationId: string) => {
|
|
8
9
|
const formData = new FormData()
|
|
9
|
-
|
|
10
|
+
// Before the file: the backend reads only the fields that precede it.
|
|
11
|
+
formData.append('organization_id', organizationId)
|
|
10
12
|
formData.append('fileName', file.name)
|
|
13
|
+
formData.append('file', file)
|
|
11
14
|
return apiClient<MediaFromDb>('/media/upload', {
|
|
12
15
|
method: 'POST',
|
|
13
16
|
body: formData,
|
package/app/api/messageGroups.ts
CHANGED
|
@@ -51,10 +51,14 @@ export const messageGroupsApi = {
|
|
|
51
51
|
delete: (id: string) => apiClient(`/message-groups/${id}`, { method: 'DELETE' }),
|
|
52
52
|
|
|
53
53
|
publish: (id: string) =>
|
|
54
|
-
apiClient<MessageGroupFromDb>(`/message-groups/${id}/publish`, {
|
|
54
|
+
apiClient<MessageGroupFromDb>(`/message-groups/${id}/publish`, {
|
|
55
|
+
method: 'PUT',
|
|
56
|
+
}),
|
|
55
57
|
|
|
56
58
|
unpublish: (id: string) =>
|
|
57
|
-
apiClient<MessageGroupFromDb>(`/message-groups/${id}/unpublish`, {
|
|
59
|
+
apiClient<MessageGroupFromDb>(`/message-groups/${id}/unpublish`, {
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
}),
|
|
58
62
|
|
|
59
63
|
validate: (id: string) =>
|
|
60
64
|
apiClient<MessageGroupValidationResponse>(`/message-groups/${id}/validate`),
|
package/app/api/meta.ts
CHANGED
|
@@ -1,22 +1,44 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
OrganizationWaba,
|
|
2
3
|
SubmissionJob,
|
|
3
4
|
SubmissionJobResponse,
|
|
5
|
+
SubmitToWabaBody,
|
|
4
6
|
} from '@dev.smartpricing/message-composer-utils/types'
|
|
5
7
|
import { apiClient } from './client'
|
|
6
8
|
|
|
9
|
+
const submitBody = (wabaId?: string): SubmitToWabaBody => (wabaId ? { waba_id: wabaId } : {})
|
|
10
|
+
|
|
7
11
|
export const metaApi = {
|
|
8
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The WABAs of an Organization, empty when it has none (ADR 0015). No error
|
|
14
|
+
* toast: a failed lookup leaves the WhatsApp configuration unknown, and the
|
|
15
|
+
* submission itself reports what is wrong.
|
|
16
|
+
*/
|
|
17
|
+
getWabas: (organizationId: string) => {
|
|
18
|
+
const params = new URLSearchParams()
|
|
19
|
+
params.set('organization_id', organizationId)
|
|
20
|
+
return apiClient<OrganizationWaba[]>(`/integrations/meta/wabas?${params.toString()}`, {
|
|
21
|
+
_skipErrorToast: true,
|
|
22
|
+
} as Record<string, unknown>)
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `wabaId` names the WABA of a first submission, when the user had to pick
|
|
27
|
+
* one. Left out, the backend uses the Message Group's WABA or the
|
|
28
|
+
* Organization's only one.
|
|
29
|
+
*/
|
|
30
|
+
submitGroup: (messageGroupId: string, wabaId?: string) =>
|
|
9
31
|
apiClient<SubmissionJobResponse>(`/integrations/meta/submit-group/${messageGroupId}`, {
|
|
10
32
|
method: 'POST',
|
|
11
|
-
body:
|
|
33
|
+
body: submitBody(wabaId),
|
|
12
34
|
}),
|
|
13
35
|
|
|
14
|
-
submitMessage: (messageId: string) =>
|
|
36
|
+
submitMessage: (messageId: string, wabaId?: string) =>
|
|
15
37
|
apiClient<{ success: boolean; templateId?: string }>(
|
|
16
38
|
`/integrations/meta/submit-message/${messageId}`,
|
|
17
39
|
{
|
|
18
40
|
method: 'POST',
|
|
19
|
-
body:
|
|
41
|
+
body: submitBody(wabaId),
|
|
20
42
|
},
|
|
21
43
|
),
|
|
22
44
|
|
package/app/api/organization.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import type { ReachableOrganization } from '@dev.smartpricing/message-composer-utils/types'
|
|
1
2
|
import { apiClient } from './client'
|
|
2
3
|
|
|
3
4
|
export const organizationApi = {
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The user's Reachable Organizations: the answer is what resolves the
|
|
7
|
+
* Effective Organization. No error toast, the Organization gate renders
|
|
8
|
+
* the failure.
|
|
9
|
+
*/
|
|
10
|
+
getReachable: () =>
|
|
11
|
+
apiClient<ReachableOrganization[]>('/organizations', {
|
|
12
|
+
_skipErrorToast: true,
|
|
13
|
+
} as Record<string, unknown>),
|
|
5
14
|
}
|
package/app/api/tracking.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { apiClient } from './client'
|
|
2
2
|
|
|
3
3
|
export const trackingApi = {
|
|
4
|
-
event: (eventName: string, properties?: Record<string, unknown>) =>
|
|
4
|
+
event: (eventName: string, organizationId: string, properties?: Record<string, unknown>) =>
|
|
5
5
|
apiClient<{ success: boolean }>('/tracking/event', {
|
|
6
6
|
method: 'POST',
|
|
7
|
-
body: { eventName, properties },
|
|
7
|
+
body: { eventName, properties, organization_id: organizationId },
|
|
8
8
|
}),
|
|
9
9
|
}
|
|
@@ -3,8 +3,8 @@ import { useAppContextStore } from '~/stores/appContext'
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Host-integration problems reported by the app-context store (missing
|
|
6
|
-
* Product,
|
|
7
|
-
*
|
|
6
|
+
* Product, ...). Shown persistently: the host has to fix its context, the
|
|
7
|
+
* user cannot.
|
|
8
8
|
*/
|
|
9
9
|
const { validationErrors } = storeToRefs(useAppContextStore())
|
|
10
10
|
const messages = computed(() => Object.values(validationErrors.value))
|
|
@@ -60,6 +60,10 @@ const props = withDefaults(
|
|
|
60
60
|
* follow the mode.
|
|
61
61
|
*/
|
|
62
62
|
eligibility?: MessageGroupEligibility
|
|
63
|
+
/** Lists this Organization's groups instead of the resolved Organization's. */
|
|
64
|
+
organizationId?: string
|
|
65
|
+
/** Lists only the WhatsApp groups on this WABA (ADR 0015); needs `renderType: 'whatsapp_v1'`. */
|
|
66
|
+
wabaId?: string
|
|
63
67
|
}>(),
|
|
64
68
|
{
|
|
65
69
|
placeholder: 'Select a message group',
|
|
@@ -88,13 +92,18 @@ const appContext = useAppContextStore()
|
|
|
88
92
|
|
|
89
93
|
// Server-side search: the menu's own client filter is off (`ignore-filter`),
|
|
90
94
|
// the typed term reaches the list request once it settles. A new search,
|
|
91
|
-
// render type
|
|
95
|
+
// render type, context Product or WABA starts over from page one.
|
|
92
96
|
const searchTerm = ref('')
|
|
93
97
|
const page = ref(1)
|
|
94
98
|
|
|
95
99
|
// Without a context Product the server would answer with every Product's
|
|
96
100
|
// groups (ADR 0011): never ask. The host's missing Product is already reported
|
|
97
101
|
// by the app-context validation.
|
|
102
|
+
// Only WhatsApp groups are on a WABA: with any other render type the filter would empty the list.
|
|
103
|
+
const wabaFilter = computed(() =>
|
|
104
|
+
props.renderType === 'whatsapp_v1' ? props.wabaId || undefined : undefined,
|
|
105
|
+
)
|
|
106
|
+
|
|
98
107
|
const {
|
|
99
108
|
query: { data: envelope, isPlaceholderData },
|
|
100
109
|
loading,
|
|
@@ -106,9 +115,11 @@ const {
|
|
|
106
115
|
filters: () => ({
|
|
107
116
|
product: appContext.product,
|
|
108
117
|
visibility: appContext.isAdmin ? 'all' : 'public',
|
|
118
|
+
waba_id: wabaFilter.value,
|
|
109
119
|
}),
|
|
110
|
-
resetOn: [() => appContext.product],
|
|
120
|
+
resetOn: [() => appContext.product, wabaFilter],
|
|
111
121
|
enabled: () => appContext.product != null,
|
|
122
|
+
organizationId: () => props.organizationId,
|
|
112
123
|
})
|
|
113
124
|
|
|
114
125
|
// Pages loaded so far, indexed by page number: page one replaces everything,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Holds its content back until there is an Organization to work on, so no
|
|
4
|
+
* resource request leaves before it is resolved (ADR 0014).
|
|
5
|
+
*/
|
|
6
|
+
const organization = useEffectiveOrganizationStore()
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
|
|
9
|
+
const failure = computed(() =>
|
|
10
|
+
organization.status === 'none'
|
|
11
|
+
? {
|
|
12
|
+
title: t('common.organization.none_title'),
|
|
13
|
+
description: t('common.organization.none_description'),
|
|
14
|
+
}
|
|
15
|
+
: {
|
|
16
|
+
title: t('common.organization.lookup_failed_title'),
|
|
17
|
+
description: t('common.organization.lookup_failed_description'),
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<slot v-if="organization.status === 'resolved'" />
|
|
24
|
+
|
|
25
|
+
<div
|
|
26
|
+
v-else-if="organization.status === 'resolving'"
|
|
27
|
+
class="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-muted"
|
|
28
|
+
role="status"
|
|
29
|
+
>
|
|
30
|
+
<UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8" />
|
|
31
|
+
<span class="text-sm">{{ t('common.organization.resolving') }}</span>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div v-else class="p-4">
|
|
35
|
+
<UAlert
|
|
36
|
+
color="error"
|
|
37
|
+
variant="subtle"
|
|
38
|
+
icon="i-lucide-alert-triangle"
|
|
39
|
+
:title="failure.title"
|
|
40
|
+
:description="failure.description"
|
|
41
|
+
:actions="[
|
|
42
|
+
{
|
|
43
|
+
label: t('common.organization.retry'),
|
|
44
|
+
color: 'error',
|
|
45
|
+
variant: 'outline',
|
|
46
|
+
onClick: () => void organization.retry(),
|
|
47
|
+
},
|
|
48
|
+
]"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Organization selector of the list pages (Message Groups, Brands, images):
|
|
4
|
+
* filters what is listed and decides where new resources are created. Renders
|
|
5
|
+
* nothing when the Host supplied the Organization or the user reaches only one.
|
|
6
|
+
*/
|
|
7
|
+
const organization = useEffectiveOrganizationStore()
|
|
8
|
+
const { t } = useI18n()
|
|
9
|
+
|
|
10
|
+
const items = computed(() =>
|
|
11
|
+
organization.reachableOrganizations.map((o) => ({ label: o.name, value: o.id })),
|
|
12
|
+
)
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<USelectMenu
|
|
17
|
+
v-if="organization.isSelectable"
|
|
18
|
+
:model-value="organization.resolvedOrganizationId ?? undefined"
|
|
19
|
+
:items="items"
|
|
20
|
+
value-key="value"
|
|
21
|
+
icon="ph:buildings"
|
|
22
|
+
:search-input="{ placeholder: t('common.organization.search_placeholder') }"
|
|
23
|
+
:aria-label="t('common.organization.label')"
|
|
24
|
+
class="w-56"
|
|
25
|
+
@update:model-value="organization.select($event)"
|
|
26
|
+
/>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Tells the user that the Effective Organization has no WABA: its WhatsApp
|
|
4
|
+
* messages can be drafted but not submitted to Meta. Follows the Organization,
|
|
5
|
+
* so it comes and goes with the selector and with the resource on screen.
|
|
6
|
+
*/
|
|
7
|
+
const { status } = useWhatsappConfiguration()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<UAlert
|
|
12
|
+
v-if="status === 'not_configured'"
|
|
13
|
+
color="warning"
|
|
14
|
+
variant="subtle"
|
|
15
|
+
icon="i-lucide-message-circle-off"
|
|
16
|
+
:title="$t('common.whatsapp_not_configured_title')"
|
|
17
|
+
:description="$t('common.whatsapp_not_configured_description')"
|
|
18
|
+
class="rounded-none"
|
|
19
|
+
/>
|
|
20
|
+
</template>
|
|
@@ -39,6 +39,10 @@ const { data: messageGroupData, isPending: isFetching } =
|
|
|
39
39
|
? useMessageGroupQuery(props.messageGroupId)
|
|
40
40
|
: { data: ref(null), isPending: ref(false) }
|
|
41
41
|
|
|
42
|
+
// The editor works on the Organization of the Message Group it loaded, not on
|
|
43
|
+
// the selector's. Claimed before the watcher below loads dependent data.
|
|
44
|
+
useResourceOrganization(() => messageGroupData.value?.organization_id)
|
|
45
|
+
|
|
42
46
|
// Change tracking (edit mode only)
|
|
43
47
|
const messageGroupIdRef = computed(() => store.messageGroupId ?? '')
|
|
44
48
|
const changeTracking = props.mode === 'edit' ? useChangeTrackingEmail(messageGroupIdRef) : null
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { OrganizationWaba } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Asks which WABA a Message Group is submitted to, at its first submission,
|
|
6
|
+
* when its Organization has several (ADR 0015). Opened through `useOverlay`:
|
|
7
|
+
* closes with the chosen WABA id, or with nothing when the user gives up.
|
|
8
|
+
*/
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
wabas: OrganizationWaba[]
|
|
11
|
+
/** The Host's WABA, already checked to be one of `wabas`. */
|
|
12
|
+
preselected?: string
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{ close: [wabaId?: string] }>()
|
|
16
|
+
|
|
17
|
+
const selected = ref<string | undefined>(props.preselected)
|
|
18
|
+
|
|
19
|
+
const items = computed(() =>
|
|
20
|
+
props.wabas.map((waba) => ({
|
|
21
|
+
value: waba.id,
|
|
22
|
+
label: waba.name || waba.id,
|
|
23
|
+
numbers: waba.phone_numbers.join(', '),
|
|
24
|
+
// Not `id`: the radio group gives each item one of its own.
|
|
25
|
+
wabaId: waba.id,
|
|
26
|
+
})),
|
|
27
|
+
)
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<UModal
|
|
32
|
+
:title="$t('dialogs.select_waba.title')"
|
|
33
|
+
:description="$t('dialogs.select_waba.description')"
|
|
34
|
+
close-icon="ph:x"
|
|
35
|
+
:close="{ onClick: () => emit('close') }"
|
|
36
|
+
:dismissible="false"
|
|
37
|
+
data-testid="waba-select-modal"
|
|
38
|
+
>
|
|
39
|
+
<template #body>
|
|
40
|
+
<URadioGroup
|
|
41
|
+
v-model="selected"
|
|
42
|
+
:items="items"
|
|
43
|
+
variant="table"
|
|
44
|
+
class="w-full"
|
|
45
|
+
:ui="{ item: 'w-full' }"
|
|
46
|
+
data-testid="waba-select-options"
|
|
47
|
+
>
|
|
48
|
+
<template #description="{ item }">
|
|
49
|
+
<span class="flex flex-col">
|
|
50
|
+
<span>{{ item.numbers || $t('dialogs.select_waba.no_numbers') }}</span>
|
|
51
|
+
<span class="text-xs text-dimmed font-mono">{{ item.wabaId }}</span>
|
|
52
|
+
</span>
|
|
53
|
+
</template>
|
|
54
|
+
</URadioGroup>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<template #footer>
|
|
58
|
+
<div class="flex gap-2 justify-end w-full">
|
|
59
|
+
<UButton
|
|
60
|
+
variant="outline"
|
|
61
|
+
color="neutral"
|
|
62
|
+
:label="$t('common.actions.cancel')"
|
|
63
|
+
data-testid="waba-select-cancel"
|
|
64
|
+
@click="emit('close')"
|
|
65
|
+
/>
|
|
66
|
+
<UButton
|
|
67
|
+
:label="$t('dialogs.select_waba.confirm')"
|
|
68
|
+
:disabled="!selected"
|
|
69
|
+
data-testid="waba-select-confirm"
|
|
70
|
+
@click="emit('close', selected)"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
</UModal>
|
|
75
|
+
</template>
|