@dev.smartpricing/message-composer-layer 4.5.1-next.2 → 5.0.0-automations.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 +132 -3
- package/app/api/automations.ts +74 -0
- package/app/api/index.ts +1 -0
- package/app/api/messageGroups.ts +5 -1
- package/app/app.config.ts +10 -0
- package/app/components/Automations/ActionCard.vue +163 -0
- package/app/components/Automations/ActionEditor.vue +376 -0
- package/app/components/Automations/Actions.vue +141 -0
- package/app/components/Automations/CloneModal.vue +74 -0
- package/app/components/Automations/Conditions.vue +146 -0
- package/app/components/Automations/Create.vue +106 -0
- package/app/components/Automations/Data.vue +46 -0
- package/app/components/Automations/DeleteModal.vue +68 -0
- package/app/components/Automations/Edit.vue +135 -0
- package/app/components/Automations/Form.vue +279 -0
- package/app/components/Automations/List.vue +343 -0
- package/app/components/Automations/PageGate.vue +50 -0
- package/app/components/Automations/SaveProblems.vue +52 -0
- package/app/components/Automations/Unavailable.vue +71 -0
- package/app/components/Automations/UnavailableBadge.vue +35 -0
- package/app/components/Common/MessageGroupSelect.vue +30 -1
- package/app/components/Composer/EmailComposer.vue +5 -1
- package/app/components/Composer/WhatsappComposer.vue +5 -1
- package/app/components/Filters/DateInput.vue +57 -0
- package/app/components/Filters/Editor.vue +163 -0
- package/app/components/Filters/Row.vue +119 -0
- package/app/components/Filters/Summary.vue +215 -0
- package/app/components/Filters/UnavailableRow.vue +74 -0
- package/app/components/Filters/ValueInput.vue +215 -0
- package/app/components/Layout/BasePage.vue +2 -0
- package/app/components/TemplateList/BaseTable.vue +0 -11
- package/app/composables/automationsContext.ts +42 -0
- package/app/composables/composerContext.ts +0 -6
- package/app/composables/useAutomationClone.ts +33 -0
- package/app/composables/useAutomationEditorTrip.ts +77 -0
- package/app/composables/useAutomationSaveProblems.ts +82 -0
- package/app/composables/useAutomationUnavailable.ts +98 -0
- package/app/composables/useAutomationsGate.ts +68 -0
- package/app/composables/useBuiltInOptionLists.ts +44 -0
- package/app/composables/useEditorTripBack.ts +15 -0
- package/app/composables/useMessageGroupWaba.ts +4 -7
- package/app/composables/useTemplateOperations.ts +8 -3
- package/app/middleware/automations-enabled.ts +12 -0
- package/app/pages/automations/[id].vue +85 -0
- package/app/pages/automations/index.vue +27 -0
- package/app/pages/automations/new.vue +51 -0
- package/app/pages/template/email/[id].vue +5 -0
- package/app/pages/template/email/create.vue +5 -0
- package/app/pages/template/whatsapp/[id].vue +5 -0
- package/app/pages/template/whatsapp/create.vue +5 -0
- package/app/queries/automations.ts +206 -0
- package/app/queries/index.ts +1 -0
- package/app/queries/messageGroups.ts +1 -3
- package/app/stores/appContext.ts +0 -3
- package/app/stores/editorTrip.ts +67 -0
- package/app/types/app-config.d.ts +3 -0
- package/app/types/tracking.ts +33 -0
- package/app/utils/automations/form.ts +368 -0
- package/app/utils/automations/metadata.ts +71 -0
- package/app/utils/automations/unavailable.ts +90 -0
- package/app/utils/filters/data/countries.json +998 -0
- package/app/utils/filters/data/languages.json +754 -0
- package/app/utils/filters/rows.ts +367 -0
- package/app/utils/testIds.const.ts +0 -1
- package/i18n/locales/de.ts +268 -1
- package/i18n/locales/en.ts +254 -1
- package/i18n/locales/es.ts +257 -1
- package/i18n/locales/fr.ts +263 -1
- package/i18n/locales/it.ts +259 -1
- package/package.json +4 -4
- package/app.config.ts +0 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The Message Composer as a [Nuxt layer](https://nuxt.com/docs/getting-started/layers). A host app extends it and gets the composer pages (message library, editor, preview, brands, images) plus embeddable components such as the **Message Send Flow**.
|
|
4
4
|
|
|
5
|
-
The layer is published on the private Smartpricing npm registry together with `@dev.smartpricing/message-composer-utils` (shared types and
|
|
5
|
+
The layer is published on the private Smartpricing npm registry together with `@dev.smartpricing/message-composer-utils` (shared types, helpers and Zod schemas, subpath exports `/types`, `/utils` and `/schemas`). Both packages share one version number.
|
|
6
6
|
|
|
7
7
|
## Host setup
|
|
8
8
|
|
|
@@ -55,7 +55,7 @@ The layer is published on the private Smartpricing npm registry together with `@
|
|
|
55
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
|
-
The layer's pages are mounted at root (`/`, `/template/...`, `/preview/...`, `/brands`, `/images`). Remap them with `pages:extend` if the host already owns those routes.
|
|
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. The Automation pages (`/automations/...`) are mounted too and answer 404 until the Host turns them on: see "Automation pages" below.
|
|
59
59
|
|
|
60
60
|
## Organization and WABA
|
|
61
61
|
|
|
@@ -85,7 +85,7 @@ watch(
|
|
|
85
85
|
)
|
|
86
86
|
```
|
|
87
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.
|
|
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
89
|
|
|
90
90
|
### Upgrading to 4.5.0
|
|
91
91
|
|
|
@@ -237,6 +237,135 @@ With tracking enabled the flow reports `send_flow.confirm` (`message_name`, `mes
|
|
|
237
237
|
|
|
238
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.
|
|
239
239
|
|
|
240
|
+
## Automation pages
|
|
241
|
+
|
|
242
|
+
Three pages list, create and edit the Automations of the Host's Product. The Automations live in the Automation Engine and the composer backend is the only way to them (ADR [0016](../../docs/adr/0016-automations-live-in-the-automation-engine.md)). The pages are off until a Host turns them on (ADR [0019](../../docs/adr/0019-automation-pages-off-until-a-host-turns-them-on.md)): an upgrade changes nothing for a Host that does not set the flag.
|
|
243
|
+
|
|
244
|
+
1. Turn them on in the Host's `app.config.ts`, next to `composer.tracking`:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
export default defineAppConfig({
|
|
248
|
+
composer: { automations: true },
|
|
249
|
+
})
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The routes always exist. While the flag is off, a layer middleware on those pages answers 404. The flag is static: a Host that wants the pages for some Organizations only does it with its own guard.
|
|
253
|
+
|
|
254
|
+
2. Provide the Automations context before an Automation page renders (`app.vue` or a plugin), as `defineComposerContext()` and apart from it:
|
|
255
|
+
|
|
256
|
+
```vue
|
|
257
|
+
<script setup lang="ts">
|
|
258
|
+
const { countries, languages } = useBuiltInOptionLists()
|
|
259
|
+
const tags = ref<AutomationOption[]>([]) // fetched and kept current by the Host
|
|
260
|
+
|
|
261
|
+
defineAutomationsContext({
|
|
262
|
+
optionLists: computed(() => ({
|
|
263
|
+
countries: countries.value,
|
|
264
|
+
languages: languages.value,
|
|
265
|
+
tags: tags.value,
|
|
266
|
+
})),
|
|
267
|
+
currency: ref('EUR'),
|
|
268
|
+
})
|
|
269
|
+
</script>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Routes
|
|
273
|
+
|
|
274
|
+
| Route name | Path | Page |
|
|
275
|
+
| ------------------- | ------------------ | ------------------------------------------------------------------------------------- |
|
|
276
|
+
| `automations-list` | `/automations` | The Automations of the Host's Product. Clone and delete are actions here, not routes. |
|
|
277
|
+
| `automation-create` | `/automations/new` | A new Automation. |
|
|
278
|
+
| `automation-edit` | `/automations/:id` | An existing Automation; a clone opens here. |
|
|
279
|
+
|
|
280
|
+
Link to them by name. The paths are the Host's to remap in `pages:extend`, and the Automation pages may sit under a different prefix than the Message pages: every link between them and the Message editor is by route name.
|
|
281
|
+
|
|
282
|
+
### `defineAutomationsContext(context)`
|
|
283
|
+
|
|
284
|
+
`AutomationsContext` and `AutomationOption` live in `app/composables/automationsContext.ts`.
|
|
285
|
+
|
|
286
|
+
| Key | Type | Description |
|
|
287
|
+
| ------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
288
|
+
| `optionLists` | `Ref<Record<string, AutomationOption[]>>` | Required. The option lists, by the name the Automation Catalog gives them (the `option_list` of a Condition Field). An `AutomationOption` is `{ value: string \| number, label: string }`, label already translated. A list that is not there shows as empty. |
|
|
289
|
+
| `currency` | `Ref<string \| undefined>` | Optional. ISO code of the currency `price` fields show; EUR when absent. Never a conversion. |
|
|
290
|
+
|
|
291
|
+
The Automation Catalog holds no option: every list is the Host's (ADR [0017](../../docs/adr/0017-automation-catalog-served-by-the-backend.md)). There are no loader callbacks: the Host fetches the lists and keeps the refs current. Countries and languages can come from `useBuiltInOptionLists()`.
|
|
292
|
+
|
|
293
|
+
### What a page needs to work
|
|
294
|
+
|
|
295
|
+
An Automation page checks three things inside the page, not in the layout, so a Host that replaces the layout keeps the check:
|
|
296
|
+
|
|
297
|
+
1. **The Automations context**: `defineAutomationsContext()` was called.
|
|
298
|
+
2. **An Automation Catalog for the Host's Product**: the backend serves one for the `product` of the composer context.
|
|
299
|
+
3. **An Organization**: the layer resolved one (see "Organization and WABA").
|
|
300
|
+
|
|
301
|
+
With the flag on and one of them missing, the page keeps its header and shows an error alert in place of its content. A failed Organization lookup or a failed catalog request shows the same alert with a retry.
|
|
302
|
+
|
|
303
|
+
The layer ships **no permission check** on these pages: the Host guards them as it guards the Message pages.
|
|
304
|
+
|
|
305
|
+
A Host sees only the Automations of its own Product. The Channels an Action can send over are the ones the catalog gives its Trigger, limited to the `channels` of the composer context, and WhatsApp only when the Organization has a WABA.
|
|
306
|
+
|
|
307
|
+
### Public and stable
|
|
308
|
+
|
|
309
|
+
This is the host contract for Automations:
|
|
310
|
+
|
|
311
|
+
- the `composer.automations` flag;
|
|
312
|
+
- the three route names: `automations-list`, `automation-create`, `automation-edit`;
|
|
313
|
+
- `defineAutomationsContext()` and the shape of `AutomationsContext`;
|
|
314
|
+
- `FiltersEditor` and `FiltersSummary`, with their props and emits;
|
|
315
|
+
- the `fields` shape and the report: `FilterField`, `FilterOption`, `FiltersReport`;
|
|
316
|
+
- `useBuiltInOptionLists()`;
|
|
317
|
+
- the shared schemas in `@dev.smartpricing/message-composer-utils/schemas`: the Condition schemas (`conditionSchema`, `conditionOperatorSchema`, `conditionFieldTypeSchema`), the request and response schemas of the Automation routes and of the Automation Catalog, and with them the operator table `CONDITION_OPERATORS_BY_FIELD_TYPE` (in `/types`).
|
|
318
|
+
|
|
319
|
+
The semver promise (ADRs [0018](../../docs/adr/0018-filter-components-speak-conditions-and-know-no-catalog.md) and [0019](../../docs/adr/0019-automation-pages-off-until-a-host-turns-them-on.md)):
|
|
320
|
+
|
|
321
|
+
- **Major**: renaming or removing a route name; removing or renaming a prop of a filter component; changing the shape of the value (`Condition[]`); dropping a field type or an operator.
|
|
322
|
+
- **Minor**: adding a prop, a field type or an operator.
|
|
323
|
+
- **No promise**: the paths (the Host's to remap), everything else under `app/components/Filters/` and `app/utils/filters/`, and the test-id pattern. They can change in any release. The components and utils of the pages themselves (`app/components/Automations/`, `app/utils/automations/`) are not on the list above.
|
|
324
|
+
|
|
325
|
+
The flag, the context and the shared schemas are host contract with no rule of their own in the ADRs: the general one applies (see "Release": a change to the host contract is a major).
|
|
326
|
+
|
|
327
|
+
## Filter components
|
|
328
|
+
|
|
329
|
+
Two props-only components edit and show a list of Conditions in the Automation Engine's shape (`{ field, operator, value? }`), on any page: they read neither the Automation Catalog nor the Host's context ([ADR 0018](../../docs/adr/0018-filter-components-speak-conditions-and-know-no-catalog.md)). The slideover, Save and Cancel around the editor belong to the page.
|
|
330
|
+
|
|
331
|
+
```vue
|
|
332
|
+
<script setup lang="ts">
|
|
333
|
+
import type {
|
|
334
|
+
Condition,
|
|
335
|
+
FilterField,
|
|
336
|
+
FiltersReport,
|
|
337
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
338
|
+
|
|
339
|
+
const { countries } = useBuiltInOptionLists()
|
|
340
|
+
|
|
341
|
+
const fields = computed<FilterField[]>(() => [
|
|
342
|
+
{ key: 'guest.country', label: t('country'), type: 'multiselect', options: countries.value },
|
|
343
|
+
{ key: 'totalPrice', label: t('total_price'), type: 'price' },
|
|
344
|
+
])
|
|
345
|
+
const conditions = ref<Condition[]>([])
|
|
346
|
+
const report = ref<FiltersReport>()
|
|
347
|
+
</script>
|
|
348
|
+
|
|
349
|
+
<template>
|
|
350
|
+
<FiltersEditor v-model="conditions" :fields="fields" title="Audience" @report="report = $event" />
|
|
351
|
+
<FiltersSummary :model-value="conditions" :fields="fields" />
|
|
352
|
+
</template>
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
| Auto-import name | Props | Emits |
|
|
356
|
+
| ------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
|
357
|
+
| `FiltersEditor` | `v-model` (`Condition[]`), `fields`, `currency?`, `title?`, `subtitle?`, `testIdPrefix?` | `update:modelValue`, `report` |
|
|
358
|
+
| `FiltersSummary` | `modelValue` (`Condition[]`), `fields`, `currency?`, `preview?` (lines before "View more", 3), `testIdPrefix?` | `report` |
|
|
359
|
+
| `useBuiltInOptionLists()` | returns `countries` and `languages`: option lists in the current language, values are lowercase ISO codes | - |
|
|
360
|
+
|
|
361
|
+
- **`fields`** is one reactive list of `{ key, label, type, options? }`, labels and options already resolved, in display order. `type` is one of `select`, `multiselect`, `number`, `price`, `date`, `text`; the operators of each are `CONDITION_OPERATORS_BY_FIELD_TYPE` in `message-composer-utils`. A field can be picked once.
|
|
362
|
+
- **Values**: a `price` is stored in cents and shown in `currency` (EUR when absent, never converted); a `date` is `YYYY-MM-DD`. "Between" is a `gte` and an `lte` on the same field. There is no "not between": the Automation Engine holds all the Conditions of a list together, so no pair of them can say "outside this range". A stored `lte` below a `gte` on one field shows as one editable row and one unavailable row.
|
|
363
|
+
- **Nothing is dropped.** A Condition on a field that is not in `fields`, with an operator or a value that does not fit the field's type, or one too many on a field, shows as an unavailable row: emitted as it came in until the user removes it. A selected option that is not in `options` shows as unavailable and is kept the same way. A row the user did not touch is emitted exactly as stored.
|
|
364
|
+
- **`report`** (`FiltersReport`, emitted on mount and on every change): `unknownFields`, `unavailable` (each Condition with its reason, `unknown_field` or `not_editable`), `unavailableOptions`, and `halfFilled`, the rows with a field and no complete value. A half-filled row is never in the emitted value. The components block nothing: the caller decides what stops a save.
|
|
365
|
+
- **Rows in progress** live in the editor only. It reads its rows again when the Conditions change from outside or when the keys or types in `fields` change, and rows in progress are then lost. Setting `v-model` to the value it already has does not reset them: to start over, remount the editor with a `key`.
|
|
366
|
+
- **Stability**: the two components, the `fields` shape, the report (`FilterField`, `FilterOption` and `FiltersReport`, in `message-composer-utils/types`), `useBuiltInOptionLists()` and the Condition schemas are host contract, usable before the Automation pages are turned on. What is a major and what is a minor is in "Public and stable" above.
|
|
367
|
+
- **Dev harness**: `/debug/filters` in `apps/frontend`.
|
|
368
|
+
|
|
240
369
|
## Development
|
|
241
370
|
|
|
242
371
|
Run from `apps/layer`:
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Automation,
|
|
3
|
+
AutomationCatalogLanguage,
|
|
4
|
+
AutomationCatalogResponse,
|
|
5
|
+
AutomationDeleteResult,
|
|
6
|
+
CloneAutomationBody,
|
|
7
|
+
CreateAutomationBody,
|
|
8
|
+
MessageGroupProduct,
|
|
9
|
+
UpdateAutomationBody,
|
|
10
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
11
|
+
import { apiClient } from './client'
|
|
12
|
+
|
|
13
|
+
/** Every request names its Organization and its Product (ADR 0014, ADR 0019). */
|
|
14
|
+
export const automationsApi = {
|
|
15
|
+
getAll: (options: { organization_id: string; product: MessageGroupProduct }) =>
|
|
16
|
+
apiClient<Automation[]>(`/automations?${new URLSearchParams(options)}`),
|
|
17
|
+
|
|
18
|
+
getCatalog: (options: {
|
|
19
|
+
organization_id: string
|
|
20
|
+
product: MessageGroupProduct
|
|
21
|
+
language: AutomationCatalogLanguage
|
|
22
|
+
}) => apiClient<AutomationCatalogResponse>(`/automation-catalog?${new URLSearchParams(options)}`),
|
|
23
|
+
|
|
24
|
+
/** No error toast: the page says in place why it has no Automation to show. */
|
|
25
|
+
getById: (id: string, options: { product: MessageGroupProduct }) =>
|
|
26
|
+
apiClient<Automation>(
|
|
27
|
+
`/automations/${encodeURIComponent(id)}?${new URLSearchParams(options)}`,
|
|
28
|
+
{
|
|
29
|
+
_skipErrorToast: true,
|
|
30
|
+
} as Record<string, unknown>,
|
|
31
|
+
),
|
|
32
|
+
|
|
33
|
+
/** No error toast: a refused save is shown in the form, by its caller. */
|
|
34
|
+
create: (body: CreateAutomationBody) =>
|
|
35
|
+
apiClient<Automation>('/automations', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
body,
|
|
38
|
+
_skipErrorToast: true,
|
|
39
|
+
} as Record<string, unknown>),
|
|
40
|
+
|
|
41
|
+
/** No error toast, as on a create: its caller tells a refused save from a half-saved one. */
|
|
42
|
+
update: (id: string, body: UpdateAutomationBody) =>
|
|
43
|
+
apiClient<Automation>(`/automations/${encodeURIComponent(id)}`, {
|
|
44
|
+
method: 'PUT',
|
|
45
|
+
body,
|
|
46
|
+
_skipErrorToast: true,
|
|
47
|
+
} as Record<string, unknown>),
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The copy of an Automation, made by the backend from what is stored: with
|
|
51
|
+
* its own Message Groups and every Action inactive (ADR 0016).
|
|
52
|
+
*/
|
|
53
|
+
clone: (id: string, body: CloneAutomationBody) =>
|
|
54
|
+
apiClient<Automation>(`/automations/${encodeURIComponent(id)}/clone`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
body,
|
|
57
|
+
}),
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Deletes the Automation and the Message Groups named, each a repeated
|
|
61
|
+
* `message_group_ids`. With none the answer is empty; with some it says
|
|
62
|
+
* which were deleted and which were kept (ADR 0016).
|
|
63
|
+
*/
|
|
64
|
+
delete: (id: string, options: { product: MessageGroupProduct; message_group_ids: string[] }) => {
|
|
65
|
+
const query = new URLSearchParams({ product: options.product })
|
|
66
|
+
for (const messageGroupId of options.message_group_ids) {
|
|
67
|
+
query.append('message_group_ids', messageGroupId)
|
|
68
|
+
}
|
|
69
|
+
return apiClient<AutomationDeleteResult | undefined>(
|
|
70
|
+
`/automations/${encodeURIComponent(id)}?${query}`,
|
|
71
|
+
{ method: 'DELETE' },
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
}
|
package/app/api/index.ts
CHANGED
package/app/api/messageGroups.ts
CHANGED
|
@@ -34,7 +34,11 @@ export const messageGroupsApi = {
|
|
|
34
34
|
`/message-groups${listQueryString(options)}`,
|
|
35
35
|
),
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/** `skipErrorToast` for a caller that expects some not to be found. */
|
|
38
|
+
getById: (id: string, options: { skipErrorToast?: boolean } = {}) =>
|
|
39
|
+
apiClient<MessageGroupWithMessages>(`/message-groups/${id}`, {
|
|
40
|
+
_skipErrorToast: options.skipErrorToast,
|
|
41
|
+
} as Record<string, unknown>),
|
|
38
42
|
|
|
39
43
|
create: (data: PostEntity<MessageGroup>) =>
|
|
40
44
|
apiClient<MessageGroupFromDb>('/message-groups', {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AppConfigInput } from 'nuxt/schema'
|
|
2
|
+
|
|
3
|
+
// Not through `defineAppConfig`: its input type asks every app config for
|
|
4
|
+
// Nuxt UI's `ui`, which only the design system's own config sets.
|
|
5
|
+
export default {
|
|
6
|
+
composer: {
|
|
7
|
+
tracking: false,
|
|
8
|
+
automations: false,
|
|
9
|
+
},
|
|
10
|
+
} satisfies Pick<AppConfigInput, 'composer'>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
AutomationChannel,
|
|
4
|
+
AutomationTrigger,
|
|
5
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
6
|
+
import {
|
|
7
|
+
dateReferenceLabel,
|
|
8
|
+
localSendingTime,
|
|
9
|
+
type AutomationFormAction,
|
|
10
|
+
} from '../../utils/automations/form'
|
|
11
|
+
import type {
|
|
12
|
+
AutomationUnavailableItem,
|
|
13
|
+
AutomationUnavailableKind,
|
|
14
|
+
} from '../../utils/automations/unavailable'
|
|
15
|
+
|
|
16
|
+
/** One Action of the form: what it sends and when, its Active switch and its buttons. */
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
action: AutomationFormAction
|
|
19
|
+
trigger: AutomationTrigger
|
|
20
|
+
/** What the Action holds that is no longer available: each is a badge here. */
|
|
21
|
+
unavailable: AutomationUnavailableItem[]
|
|
22
|
+
/** A refused save pointed at this Action. */
|
|
23
|
+
invalid?: boolean
|
|
24
|
+
/** The Action is not edited or removed here: its Active switch still works. */
|
|
25
|
+
readOnly?: boolean
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{
|
|
29
|
+
edit: []
|
|
30
|
+
remove: []
|
|
31
|
+
toggle: [active: boolean]
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const { t } = useI18n()
|
|
35
|
+
|
|
36
|
+
const CHANNEL_ICONS: Record<AutomationChannel, string> = {
|
|
37
|
+
email: 'ph:envelope-simple-bold',
|
|
38
|
+
whatsapp: 'ph:whatsapp-logo-bold',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const schedule = computed(() => {
|
|
42
|
+
// `moment` is the name the ported copy gives the Date Reference.
|
|
43
|
+
const moment = dateReferenceLabel(props.action, props.trigger)
|
|
44
|
+
const { days } = props.action
|
|
45
|
+
if (days === 0) return t('automations.immediately', { moment })
|
|
46
|
+
if (days === 1) return t('automations.oneDay', { moment })
|
|
47
|
+
return t('automations.nDays', { count: days, moment })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function unavailableOf(...kinds: AutomationUnavailableKind[]) {
|
|
51
|
+
return props.unavailable.find((item) => kinds.includes(item.kind))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const unusableChannel = computed(() => unavailableOf('channel'))
|
|
55
|
+
const unlistedDateReference = computed(() => unavailableOf('date_reference'))
|
|
56
|
+
const unapproved = computed(() => unavailableOf('message_group_inactive', 'message_group_active'))
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Why the Action cannot be turned on: its Channel cannot be used, or its
|
|
60
|
+
* Message Group is not approved. An Active one on an unapproved Message Group
|
|
61
|
+
* keeps a switch that works, so the user can turn it off.
|
|
62
|
+
*/
|
|
63
|
+
const cannotBeActive = computed(
|
|
64
|
+
() => unusableChannel.value ?? unavailableOf('message_group_inactive'),
|
|
65
|
+
)
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<UCard :class="{ 'ring-error': invalid }">
|
|
70
|
+
<div class="flex items-start gap-4">
|
|
71
|
+
<UAvatar :icon="CHANNEL_ICONS[action.channel]" size="xl" class="shrink-0" />
|
|
72
|
+
|
|
73
|
+
<div class="flex-1 min-w-0">
|
|
74
|
+
<div class="flex items-center gap-2">
|
|
75
|
+
<p class="label-md truncate">
|
|
76
|
+
{{ action.messageGroupName || t(`automations.channels.${action.channel}`) }}
|
|
77
|
+
</p>
|
|
78
|
+
<AutomationsUnavailableBadge v-if="unapproved" :item="unapproved" />
|
|
79
|
+
<AutomationsUnavailableBadge v-if="unusableChannel" :item="unusableChannel" />
|
|
80
|
+
</div>
|
|
81
|
+
<p class="mt-2 paragraph-sm text-muted">
|
|
82
|
+
{{ t('automations.action.itIsSent') }}
|
|
83
|
+
<span class="font-semibold text-default">{{ schedule }}</span>
|
|
84
|
+
<AutomationsUnavailableBadge
|
|
85
|
+
v-if="unlistedDateReference"
|
|
86
|
+
:item="unlistedDateReference"
|
|
87
|
+
class="ml-2"
|
|
88
|
+
/>
|
|
89
|
+
<span class="mx-2">•</span>
|
|
90
|
+
{{ t('automations.action.at') }}
|
|
91
|
+
<span class="font-semibold text-default">
|
|
92
|
+
{{ localSendingTime(action.sendingTimeUTC) }}
|
|
93
|
+
</span>
|
|
94
|
+
<template v-if="action.recurrence">
|
|
95
|
+
<span class="mx-2">•</span>
|
|
96
|
+
<span class="font-semibold text-default">
|
|
97
|
+
{{ t(`automations.recurrence.${action.recurrence}`) }}
|
|
98
|
+
</span>
|
|
99
|
+
</template>
|
|
100
|
+
<template v-if="action.strictDelay">
|
|
101
|
+
<span class="mx-2">•</span>
|
|
102
|
+
<UBadge :label="t('automations.strictDelay.label')" color="neutral" size="sm" />
|
|
103
|
+
</template>
|
|
104
|
+
<template v-if="action.preventDuplicates">
|
|
105
|
+
<span class="mx-2">•</span>
|
|
106
|
+
<UBadge :label="t('automations.preventDuplicates.label')" color="neutral" size="sm" />
|
|
107
|
+
</template>
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<template #footer>
|
|
113
|
+
<div class="flex items-center gap-1">
|
|
114
|
+
<!-- An Action on a Channel that cannot be used is kept as stored, and shown as not Active. -->
|
|
115
|
+
<UTooltip :text="cannotBeActive?.text" :disabled="!cannotBeActive">
|
|
116
|
+
<!-- A disabled switch takes no hover: the tooltip is on what wraps it. -->
|
|
117
|
+
<span class="inline-flex">
|
|
118
|
+
<USwitch
|
|
119
|
+
:model-value="action.status === 'active' && !unusableChannel"
|
|
120
|
+
:disabled="Boolean(cannotBeActive)"
|
|
121
|
+
size="sm"
|
|
122
|
+
color="secondary"
|
|
123
|
+
:label="t('automations.list.active')"
|
|
124
|
+
@update:model-value="emit('toggle', $event)"
|
|
125
|
+
/>
|
|
126
|
+
</span>
|
|
127
|
+
</UTooltip>
|
|
128
|
+
|
|
129
|
+
<div class="ml-auto flex items-center gap-1">
|
|
130
|
+
<UButton
|
|
131
|
+
v-if="!readOnly"
|
|
132
|
+
color="error"
|
|
133
|
+
variant="ghost"
|
|
134
|
+
size="sm"
|
|
135
|
+
:label="t('common.actions.delete')"
|
|
136
|
+
@click="emit('remove')"
|
|
137
|
+
/>
|
|
138
|
+
<USlideover
|
|
139
|
+
close-icon="ph:x"
|
|
140
|
+
:title="t('common.preview')"
|
|
141
|
+
:ui="{ content: 'max-w-2xl', body: 'p-0 sm:p-0' }"
|
|
142
|
+
>
|
|
143
|
+
<UButton color="primary" variant="outline" size="sm" :label="t('common.preview')" />
|
|
144
|
+
|
|
145
|
+
<template #body>
|
|
146
|
+
<CommonPreviewById
|
|
147
|
+
:id="action.messageGroupId"
|
|
148
|
+
class="w-full h-full min-h-[400px]"
|
|
149
|
+
show-subject
|
|
150
|
+
/>
|
|
151
|
+
</template>
|
|
152
|
+
</USlideover>
|
|
153
|
+
<UButton
|
|
154
|
+
v-if="!readOnly"
|
|
155
|
+
size="sm"
|
|
156
|
+
:label="t('common.actions.edit')"
|
|
157
|
+
@click="emit('edit')"
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</template>
|
|
162
|
+
</UCard>
|
|
163
|
+
</template>
|