@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.
Files changed (52) hide show
  1. package/README.md +51 -5
  2. package/app/api/brands.ts +20 -13
  3. package/app/api/client.ts +3 -6
  4. package/app/api/dynamicValues.ts +2 -1
  5. package/app/api/media.ts +6 -3
  6. package/app/api/messageGroups.ts +6 -2
  7. package/app/api/meta.ts +26 -4
  8. package/app/api/organization.ts +10 -1
  9. package/app/api/tracking.ts +2 -2
  10. package/app/components/Common/AppContextErrors.vue +2 -2
  11. package/app/components/Common/MessageGroupSelect.vue +13 -2
  12. package/app/components/Common/OrganizationGate.vue +51 -0
  13. package/app/components/Common/OrganizationSelect.vue +27 -0
  14. package/app/components/Common/WhatsappNotConfigured.vue +20 -0
  15. package/app/components/Composer/EmailComposer.vue +4 -0
  16. package/app/components/Composer/WabaSelectModal.vue +75 -0
  17. package/app/components/Composer/WhatsappComposer.vue +78 -3
  18. package/app/components/Editor/RichTextEditor.vue +14 -2
  19. package/app/components/MessageSendFlow/EmailPreview.vue +3 -0
  20. package/app/components/MessageSendFlow/MessageSendFlow.vue +15 -2
  21. package/app/components/Whatsapp/Preview.vue +5 -12
  22. package/app/components/Whatsapp/Translator/Header.vue +4 -19
  23. package/app/composables/composerContext.ts +5 -5
  24. package/app/composables/useEffectiveOrganization.ts +34 -0
  25. package/app/composables/useMessageGroupWaba.ts +73 -0
  26. package/app/composables/useMessageSendFlow.ts +46 -8
  27. package/app/composables/usePagedMessageGroups.ts +14 -2
  28. package/app/composables/useTemplateOperations.ts +10 -1
  29. package/app/composables/useTracking.ts +14 -5
  30. package/app/composables/useWhatsappConfiguration.ts +32 -0
  31. package/app/layouts/default.vue +4 -1
  32. package/app/pages/brands.vue +1 -0
  33. package/app/pages/images.vue +4 -0
  34. package/app/pages/index.vue +1 -0
  35. package/app/pages/preview/[id].vue +2 -0
  36. package/app/pages/template/whatsapp/[id].vue +1 -0
  37. package/app/queries/brands.ts +27 -7
  38. package/app/queries/compilation.ts +15 -2
  39. package/app/queries/dynamicValues.ts +13 -2
  40. package/app/queries/media.ts +8 -3
  41. package/app/queries/messageGroups.ts +31 -5
  42. package/app/queries/meta.ts +13 -8
  43. package/app/stores/appContext.ts +9 -45
  44. package/app/stores/effectiveOrganization.ts +128 -0
  45. package/app/stores/emailEditor.ts +5 -1
  46. package/app/stores/metaSubmission.ts +7 -17
  47. package/i18n/locales/de.ts +30 -0
  48. package/i18n/locales/en.ts +29 -0
  49. package/i18n/locales/es.ts +29 -0
  50. package/i18n/locales/fr.ts +30 -0
  51. package/i18n/locales/it.ts +29 -0
  52. 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
- metaWabaId: useAuth().wabaId, // required for the whatsapp channel
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?: { sort_by?: 'created_at' | 'name'; direction?: 'asc' | 'desc' }) => {
20
- const params = new URLSearchParams()
21
- if (options?.sort_by) params.set('sort_by', options.sort_by)
22
- if (options?.direction) params.set('direction', options.direction)
23
- const query = params.size ? `?${params.toString()}` : ''
24
- return apiClient<BrandWithProperties[]>(`/brands${query}`)
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: () => apiClient<Property[]>('/properties'),
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
  }
@@ -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: () => apiClient<MediaFromDb[]>('/media'),
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
- formData.append('file', file)
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,
@@ -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`, { method: 'PUT' }),
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`, { method: 'PUT' }),
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
- submitGroup: (messageGroupId: string) =>
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
 
@@ -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
- getData: () => apiClient<Record<string, string>>('/organization/data'),
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
  }
@@ -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, missing Meta config, ...). Shown persistently: the host has to fix
7
- * its context, the user cannot.
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 or context Product starts over from page one.
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>