@dev.smartpricing/message-composer-layer 4.5.1-next.3 → 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.
Files changed (72) hide show
  1. package/README.md +133 -4
  2. package/app/api/automations.ts +74 -0
  3. package/app/api/index.ts +1 -0
  4. package/app/api/messageGroups.ts +5 -1
  5. package/app/app.config.ts +10 -0
  6. package/app/components/Automations/ActionCard.vue +163 -0
  7. package/app/components/Automations/ActionEditor.vue +376 -0
  8. package/app/components/Automations/Actions.vue +141 -0
  9. package/app/components/Automations/CloneModal.vue +74 -0
  10. package/app/components/Automations/Conditions.vue +146 -0
  11. package/app/components/Automations/Create.vue +106 -0
  12. package/app/components/Automations/Data.vue +46 -0
  13. package/app/components/Automations/DeleteModal.vue +68 -0
  14. package/app/components/Automations/Edit.vue +135 -0
  15. package/app/components/Automations/Form.vue +279 -0
  16. package/app/components/Automations/List.vue +343 -0
  17. package/app/components/Automations/PageGate.vue +50 -0
  18. package/app/components/Automations/SaveProblems.vue +52 -0
  19. package/app/components/Automations/Unavailable.vue +71 -0
  20. package/app/components/Automations/UnavailableBadge.vue +35 -0
  21. package/app/components/Common/MessageGroupSelect.vue +30 -1
  22. package/app/components/Composer/EmailComposer.vue +5 -1
  23. package/app/components/Composer/WabaSelectModal.vue +3 -1
  24. package/app/components/Composer/WhatsappComposer.vue +5 -1
  25. package/app/components/Filters/DateInput.vue +57 -0
  26. package/app/components/Filters/Editor.vue +163 -0
  27. package/app/components/Filters/Row.vue +119 -0
  28. package/app/components/Filters/Summary.vue +215 -0
  29. package/app/components/Filters/UnavailableRow.vue +74 -0
  30. package/app/components/Filters/ValueInput.vue +215 -0
  31. package/app/components/Layout/BasePage.vue +2 -0
  32. package/app/components/TemplateList/BaseTable.vue +1 -22
  33. package/app/composables/automationsContext.ts +42 -0
  34. package/app/composables/composerContext.ts +3 -3
  35. package/app/composables/useAutomationClone.ts +33 -0
  36. package/app/composables/useAutomationEditorTrip.ts +77 -0
  37. package/app/composables/useAutomationSaveProblems.ts +82 -0
  38. package/app/composables/useAutomationUnavailable.ts +98 -0
  39. package/app/composables/useAutomationsGate.ts +68 -0
  40. package/app/composables/useBuiltInOptionLists.ts +44 -0
  41. package/app/composables/useEditorTripBack.ts +15 -0
  42. package/app/composables/useMessageGroupWaba.ts +7 -10
  43. package/app/composables/useTemplateOperations.ts +8 -3
  44. package/app/middleware/automations-enabled.ts +12 -0
  45. package/app/pages/automations/[id].vue +85 -0
  46. package/app/pages/automations/index.vue +27 -0
  47. package/app/pages/automations/new.vue +51 -0
  48. package/app/pages/template/email/[id].vue +5 -0
  49. package/app/pages/template/email/create.vue +5 -0
  50. package/app/pages/template/whatsapp/[id].vue +5 -0
  51. package/app/pages/template/whatsapp/create.vue +5 -0
  52. package/app/queries/automations.ts +206 -0
  53. package/app/queries/index.ts +1 -0
  54. package/app/queries/messageGroups.ts +1 -3
  55. package/app/stores/appContext.ts +1 -1
  56. package/app/stores/editorTrip.ts +67 -0
  57. package/app/types/app-config.d.ts +3 -0
  58. package/app/types/tracking.ts +33 -0
  59. package/app/utils/automations/form.ts +368 -0
  60. package/app/utils/automations/metadata.ts +71 -0
  61. package/app/utils/automations/unavailable.ts +90 -0
  62. package/app/utils/filters/data/countries.json +998 -0
  63. package/app/utils/filters/data/languages.json +754 -0
  64. package/app/utils/filters/rows.ts +367 -0
  65. package/app/utils/testIds.const.ts +0 -1
  66. package/i18n/locales/de.ts +268 -1
  67. package/i18n/locales/en.ts +254 -1
  68. package/i18n/locales/es.ts +257 -1
  69. package/i18n/locales/fr.ts +263 -1
  70. package/i18n/locales/it.ts +259 -1
  71. package/package.json +4 -4
  72. package/app.config.ts +0 -5
@@ -0,0 +1,146 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ AutomationTrigger,
4
+ Condition,
5
+ FilterField,
6
+ FiltersReport,
7
+ } from '@dev.smartpricing/message-composer-utils/types'
8
+ import type { AutomationOption } from '~/composables/automationsContext'
9
+ import { cloneFormData } from '../../utils/automations/form'
10
+
11
+ /**
12
+ * The Conditions of an Automation: shown with `FiltersSummary`, edited with
13
+ * `FiltersEditor` in a slideover that changes nothing until its Save
14
+ * (ADR 0018). The fields are the Trigger's Condition Fields, each with the
15
+ * option list the Host supplies under the name the field declares (ADR 0017).
16
+ */
17
+ const props = defineProps<{
18
+ trigger: AutomationTrigger
19
+ /** The Host's option lists, by name. One that is not there shows as empty. */
20
+ optionLists: Record<string, AutomationOption[]>
21
+ /** ISO code of the currency `price` fields show; EUR when absent. */
22
+ currency?: string
23
+ /** The Conditions are only shown: there is no way into the filter editor. */
24
+ readOnly?: boolean
25
+ }>()
26
+
27
+ const conditions = defineModel<Condition[]>({ required: true })
28
+ /** Whether the filter editor is open. */
29
+ const open = defineModel<boolean>('open', { default: false })
30
+
31
+ const emit = defineEmits<{
32
+ /** What is wrong with the Conditions as they stand; `undefined` when there is none. */
33
+ report: [report: FiltersReport | undefined]
34
+ }>()
35
+
36
+ const { t } = useI18n()
37
+ const unavailable = useAutomationUnavailable()
38
+
39
+ const fields = computed<FilterField[]>(() =>
40
+ props.trigger.condition_fields.map(({ key, label, type, option_list }) => ({
41
+ key,
42
+ label,
43
+ type,
44
+ options: option_list ? (props.optionLists[option_list] ?? []) : undefined,
45
+ })),
46
+ )
47
+
48
+ // On screen a Condition is a "filter", and a "between" is one of them.
49
+ const filterCount = computed(() => new Set(conditions.value.map(({ field }) => field)).size)
50
+
51
+ // `FiltersSummary` is not there without Conditions, and then reports nothing.
52
+ watch(
53
+ () => conditions.value.length,
54
+ (count) => {
55
+ if (count === 0) emit('report', undefined)
56
+ },
57
+ )
58
+
59
+ /** What the slideover edits: the Conditions change only on its Save. */
60
+ const edited = ref<Condition[]>([])
61
+ const report = ref<FiltersReport>()
62
+
63
+ /** The alert of the slideover: what is unavailable in the Conditions being edited. */
64
+ const editedUnavailable = computed(() => unavailable.conditionItems(report.value, fields.value))
65
+
66
+ watch(open, (isOpen) => {
67
+ if (isOpen) edited.value = cloneFormData(conditions.value)
68
+ })
69
+
70
+ function save() {
71
+ conditions.value = edited.value
72
+ open.value = false
73
+ }
74
+ </script>
75
+
76
+ <template>
77
+ <UFormField
78
+ :label="t('automations.form.sendTotitle')"
79
+ :description="t('automations.form.sendToHint')"
80
+ >
81
+ <div class="flex items-center gap-1">
82
+ <p class="paragraph-sm text-primary-800">
83
+ {{
84
+ filterCount > 0
85
+ ? t('filters.applied', { count: filterCount })
86
+ : t('automations.form.sendTo_all')
87
+ }}
88
+ </p>
89
+
90
+ <USlideover
91
+ v-if="!readOnly"
92
+ v-model:open="open"
93
+ close-icon="ph:x"
94
+ :title="t('automations.form.create_specific_audience')"
95
+ :description="t('automations.form.create_specific_audience_hint')"
96
+ :ui="{ content: 'max-w-4xl', footer: 'justify-end' }"
97
+ >
98
+ <UButton
99
+ color="secondary"
100
+ variant="ghost"
101
+ size="sm"
102
+ :icon="filterCount > 0 ? 'ph:pencil-simple-line-bold' : 'ph:plus-bold'"
103
+ :label="filterCount > 0 ? t('common.actions.edit') : t('automations.form.addFilters')"
104
+ />
105
+
106
+ <template #body>
107
+ <AutomationsUnavailable :items="editedUnavailable" in-place class="mb-4" />
108
+ <FiltersEditor
109
+ v-model="edited"
110
+ :fields="fields"
111
+ :currency="currency"
112
+ :title="t('automations.form.includeIf')"
113
+ test-id-prefix="automation"
114
+ @report="report = $event"
115
+ />
116
+ </template>
117
+
118
+ <template #footer>
119
+ <UButton
120
+ color="primary"
121
+ variant="outline"
122
+ :label="t('common.actions.cancel')"
123
+ @click="open = false"
124
+ />
125
+ <!-- A half-filled row is in no Condition: saving would drop it without a word. -->
126
+ <UButton
127
+ color="primary"
128
+ :label="t('common.actions.save')"
129
+ :disabled="Boolean(report?.halfFilled)"
130
+ @click="save"
131
+ />
132
+ </template>
133
+ </USlideover>
134
+ </div>
135
+
136
+ <FiltersSummary
137
+ v-if="conditions.length > 0"
138
+ :model-value="conditions"
139
+ :fields="fields"
140
+ :currency="currency"
141
+ test-id-prefix="automation"
142
+ class="mt-3"
143
+ @report="emit('report', $event)"
144
+ />
145
+ </UFormField>
146
+ </template>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ AutomationCatalog,
4
+ AutomationTrigger,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+ import { resolveErrorToast } from '~/composables/apiErrorMessages'
7
+ import type { AutomationsContext } from '~/composables/automationsContext'
8
+ import { useCreateAutomationMutation } from '~/queries'
9
+ import {
10
+ automationSaveProblems,
11
+ automationSetupProperties,
12
+ cloneFormData,
13
+ newAutomationForm,
14
+ toCreateAutomationBody,
15
+ type AutomationFormState,
16
+ } from '../../utils/automations/form'
17
+
18
+ /**
19
+ * A new Automation: the form, its save, and what a refused save says. The
20
+ * page around it holds the header and calls `submit`.
21
+ */
22
+ const props = defineProps<{
23
+ catalog: AutomationCatalog
24
+ /** The Trigger a new Automation starts on. */
25
+ initialTrigger: AutomationTrigger
26
+ context: AutomationsContext
27
+ }>()
28
+
29
+ const { t } = useI18n()
30
+ const toast = useToast()
31
+ const { trackEvent } = useTracking()
32
+
33
+ const trip = useAutomationEditorTrip('automation-create')
34
+
35
+ const form = ref<AutomationFormState>(
36
+ trip.restored?.form ?? newAutomationForm(props.initialTrigger),
37
+ )
38
+ const submitted = ref(false)
39
+ const formElement = useTemplateRef('formElement')
40
+
41
+ const incomplete = computed(() => !form.value.name.trim() || form.value.actions.length === 0)
42
+
43
+ const problems = useAutomationSaveProblems(() => props.catalog)
44
+
45
+ const { mutateAsync: createAutomation, isLoading: saving } = useCreateAutomationMutation()
46
+
47
+ async function submit() {
48
+ submitted.value = true
49
+ problems.clear()
50
+ // Something unavailable stops the save: nothing is sent, the alert says what to fix.
51
+ if (formElement.value?.blocked) {
52
+ formElement.value.showUnavailable()
53
+ return
54
+ }
55
+ if (incomplete.value || saving.value) return
56
+
57
+ const sent = cloneFormData(form.value)
58
+ let created
59
+ try {
60
+ created = await createAutomation(toCreateAutomationBody(sent))
61
+ } catch (error) {
62
+ const refused = automationSaveProblems(error)
63
+ if (refused) {
64
+ problems.show(refused, sent)
65
+ } else {
66
+ // The request carries no error toast of its own, so a 422 gets none.
67
+ const { data, status = 0 } = error as { data?: { code?: string }; status?: number }
68
+ toast.add({ ...resolveErrorToast({ status, code: data?.code }, t), color: 'error' })
69
+ }
70
+ return
71
+ }
72
+
73
+ trackEvent(
74
+ 'Automation setup',
75
+ automationSetupProperties(
76
+ sent,
77
+ props.catalog.triggers.find(({ type }) => type === sent.triggerType),
78
+ ),
79
+ )
80
+ toast.add({ title: t('automations.toast.created'), color: 'success' })
81
+ // A body that is not an Automation (ADR 0016) has no Automation to open.
82
+ await navigateTo(
83
+ created?.id
84
+ ? { name: 'automation-edit', params: { id: created.id } }
85
+ : { name: 'automations-list' },
86
+ { replace: true },
87
+ )
88
+ }
89
+
90
+ defineExpose({ submit, saving })
91
+ </script>
92
+
93
+ <template>
94
+ <AutomationsSaveProblems :lines="problems.lines.value" :incomplete="submitted && incomplete" />
95
+
96
+ <AutomationsForm
97
+ ref="formElement"
98
+ v-model="form"
99
+ :catalog="catalog"
100
+ :context="context"
101
+ :submitted="submitted"
102
+ :invalid-action-keys="problems.invalidActionKeys.value"
103
+ :open-action="trip.restored?.openAction"
104
+ @edit-message="trip.leave({ form }, $event)"
105
+ />
106
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import type { AutomationMetadataForm } from '../../utils/automations/metadata'
3
+
4
+ /**
5
+ * The values an Automation asks its user for: one text field per field of
6
+ * its stored `metadata.form`, whatever the Automation. Description and labels
7
+ * are its Host's words, not the layer's: one that is a key of the Host's own
8
+ * translations is shown translated, any other as stored.
9
+ */
10
+ defineProps<{ form: AutomationMetadataForm }>()
11
+
12
+ /** The values, by the key of their field. */
13
+ const data = defineModel<Record<string, string>>({ required: true })
14
+
15
+ const { t, te } = useI18n()
16
+
17
+ /** Smartconnect seeds its labels as keys of its own locale files. */
18
+ function hostText(stored: string): string {
19
+ return te(stored) ? t(stored) : stored
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <div class="space-y-3">
25
+ <div class="flex items-center gap-2">
26
+ <h3 class="heading-sm">{{ t('automations.form.data.title') }}</h3>
27
+ <UBadge
28
+ v-if="form.fields.some((field) => field.required)"
29
+ :label="t('automations.form.data.requiredBadge')"
30
+ color="info"
31
+ variant="subtle"
32
+ />
33
+ </div>
34
+
35
+ <p v-if="form.description" class="paragraph-sm text-muted">{{ hostText(form.description) }}</p>
36
+
37
+ <UFormField
38
+ v-for="field in form.fields"
39
+ :key="field.key"
40
+ :label="hostText(field.label)"
41
+ :required="field.required"
42
+ >
43
+ <UInput v-model="data[field.key]" class="w-full" />
44
+ </UFormField>
45
+ </div>
46
+ </template>
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import type { Automation } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { useAutomationMessageGroupsQuery } from '~/queries'
4
+
5
+ /**
6
+ * Asks before an Automation is deleted, and which of its Message Groups go
7
+ * with it: none unless the user checks it (ADR 0016). Opened through
8
+ * `useOverlay`; `action` does the delete while the modal shows it running.
9
+ */
10
+ const props = defineProps<{
11
+ automation: Automation
12
+ action: (messageGroupIds: string[]) => Promise<void>
13
+ }>()
14
+
15
+ const { t } = useI18n()
16
+
17
+ const { data: messageGroups, isPending } = useAutomationMessageGroupsQuery(() => props.automation)
18
+
19
+ const items = computed(() =>
20
+ (messageGroups.value ?? []).map(({ id, name }) => ({ value: id, label: name })),
21
+ )
22
+ const selected = ref<string[]>([])
23
+ const deleting = ref(false)
24
+
25
+ async function confirmDelete() {
26
+ deleting.value = true
27
+ try {
28
+ await props.action(selected.value)
29
+ } catch {
30
+ // Already said by the request's own error toast; the modal closes.
31
+ } finally {
32
+ deleting.value = false
33
+ }
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <SConfirmModal
39
+ :modal-props="{ title: t('automations.delete.title') }"
40
+ :confirm-props="{ label: t('common.actions.delete'), disabled: isPending }"
41
+ :cancel-props="{ label: t('common.actions.cancel') }"
42
+ :loading="deleting"
43
+ :action="confirmDelete"
44
+ destructive
45
+ >
46
+ <template #body>
47
+ <div class="space-y-4">
48
+ <p class="my-0 text-muted">{{ t('automations.delete.confirm') }}</p>
49
+
50
+ <!-- No delete before the choice is on screen: it would skip it. -->
51
+ <USkeleton v-if="isPending" class="h-12 w-full" />
52
+
53
+ <template v-else-if="items.length > 0">
54
+ <UCheckboxGroup
55
+ v-model="selected"
56
+ :items="items"
57
+ :legend="t('automations.delete.messages')"
58
+ :disabled="deleting"
59
+ />
60
+ <p class="my-0 flex items-start gap-1.5 paragraph-sm text-warning">
61
+ <UIcon name="ph:warning" class="mt-0.5 shrink-0" />
62
+ {{ t('automations.delete.warning') }}
63
+ </p>
64
+ </template>
65
+ </div>
66
+ </template>
67
+ </SConfirmModal>
68
+ </template>
@@ -0,0 +1,135 @@
1
+ <script setup lang="ts">
2
+ import type { Automation, AutomationCatalog } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { resolveErrorToast } from '~/composables/apiErrorMessages'
4
+ import type { AutomationsContext } from '~/composables/automationsContext'
5
+ import { useUpdateAutomationMutation } from '~/queries'
6
+ import {
7
+ automationPartlySaved,
8
+ automationSaveProblems,
9
+ automationSetupProperties,
10
+ cloneFormData,
11
+ toAutomationForm,
12
+ toUpdateAutomationBody,
13
+ withPartlySavedActions,
14
+ type AutomationFormState,
15
+ } from '../../utils/automations/form'
16
+ import { automationMetadataForm, isSystemAutomation } from '../../utils/automations/metadata'
17
+
18
+ /**
19
+ * An existing Automation: the form, its save, and what a refused or a
20
+ * half-saved save says. The page around it holds the header and calls
21
+ * `submit`.
22
+ */
23
+ const props = defineProps<{
24
+ catalog: AutomationCatalog
25
+ /** The Automation as it was read. The form starts from it and does not follow it. */
26
+ automation: Automation
27
+ context: AutomationsContext
28
+ }>()
29
+
30
+ const { t } = useI18n()
31
+ const toast = useToast()
32
+ const { trackEvent } = useTracking()
33
+
34
+ const trip = useAutomationEditorTrip('automation-edit', props.automation.id)
35
+
36
+ /** The Automation as the Automation Engine holds it: what a save is an update of. */
37
+ const stored = shallowRef(trip.restored?.stored ?? props.automation)
38
+ const form = ref<AutomationFormState>(trip.restored?.form ?? toAutomationForm(props.automation))
39
+ const submitted = ref(false)
40
+ const formElement = useTemplateRef('formElement')
41
+
42
+ /** A System Automation: the form lets through only what its user may change. */
43
+ const system = computed(() => isSystemAutomation(stored.value))
44
+ const dataForm = computed(() => automationMetadataForm(stored.value.metadata))
45
+ const { askClone } = useAutomationClone()
46
+
47
+ const incomplete = computed(() => !form.value.name.trim() || form.value.actions.length === 0)
48
+
49
+ const problems = useAutomationSaveProblems(() => props.catalog)
50
+
51
+ const { mutateAsync: updateAutomation, isLoading: saving } = useUpdateAutomationMutation()
52
+
53
+ async function submit() {
54
+ submitted.value = true
55
+ problems.clear()
56
+ // Something unavailable stops the save: nothing is sent, the alert says what to fix.
57
+ if (formElement.value?.blocked) {
58
+ formElement.value.showUnavailable()
59
+ return
60
+ }
61
+ if (incomplete.value || saving.value) return
62
+
63
+ const sent = cloneFormData(form.value)
64
+ try {
65
+ await updateAutomation({ id: stored.value.id, ...toUpdateAutomationBody(sent, stored.value) })
66
+ } catch (error) {
67
+ const refused = automationSaveProblems(error)
68
+ const partlySaved = automationPartlySaved(error)
69
+ if (refused) {
70
+ problems.show(refused, sent)
71
+ } else if (partlySaved) {
72
+ // Nothing was rolled back (ADR 0016): the next save is an update of
73
+ // what the Automation Engine now holds; the form stays as the user left it.
74
+ stored.value = partlySaved.automation
75
+ form.value.actions = withPartlySavedActions(form.value.actions, sent.actions, partlySaved)
76
+ toast.add({
77
+ title: t('automations.toast.partly_saved'),
78
+ description: t('automations.toast.partly_saved_description'),
79
+ color: 'error',
80
+ })
81
+ } else {
82
+ // The request carries no error toast of its own, so a 422 gets none.
83
+ const { data, status = 0 } = error as { data?: { code?: string }; status?: number }
84
+ toast.add({ ...resolveErrorToast({ status, code: data?.code }, t), color: 'error' })
85
+ }
86
+ return
87
+ }
88
+
89
+ trackEvent(
90
+ 'Automation setup',
91
+ automationSetupProperties(
92
+ sent,
93
+ props.catalog.triggers.find(({ type }) => type === sent.triggerType),
94
+ ),
95
+ )
96
+ toast.add({ title: t('automations.toast.updated'), color: 'success' })
97
+ await navigateTo({ name: 'automations-list' })
98
+ }
99
+
100
+ defineExpose({ submit, saving })
101
+ </script>
102
+
103
+ <template>
104
+ <UAlert
105
+ v-if="system"
106
+ color="info"
107
+ variant="soft"
108
+ icon="ph:lock-simple-bold"
109
+ :title="t('automations.system.alert')"
110
+ :actions="[
111
+ {
112
+ label: t('common.actions.duplicate'),
113
+ color: 'info',
114
+ variant: 'outline',
115
+ size: 'sm',
116
+ onClick: () => askClone(stored),
117
+ },
118
+ ]"
119
+ />
120
+
121
+ <AutomationsSaveProblems :lines="problems.lines.value" :incomplete="submitted && incomplete" />
122
+
123
+ <AutomationsForm
124
+ ref="formElement"
125
+ v-model="form"
126
+ :catalog="catalog"
127
+ :context="context"
128
+ :submitted="submitted"
129
+ :invalid-action-keys="problems.invalidActionKeys.value"
130
+ :open-action="trip.restored?.openAction"
131
+ :data-form="dataForm"
132
+ :system="system"
133
+ @edit-message="trip.leave({ form, stored }, $event)"
134
+ />
135
+ </template>