@dev.smartpricing/message-composer-layer 4.5.1-next.2 → 4.5.1-next.4

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 +132 -3
  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 +174 -0
  7. package/app/components/Automations/ActionEditor.vue +398 -0
  8. package/app/components/Automations/Actions.vue +148 -0
  9. package/app/components/Automations/CloneModal.vue +78 -0
  10. package/app/components/Automations/Conditions.vue +155 -0
  11. package/app/components/Automations/Create.vue +114 -0
  12. package/app/components/Automations/Data.vue +46 -0
  13. package/app/components/Automations/DeleteModal.vue +80 -0
  14. package/app/components/Automations/Edit.vue +145 -0
  15. package/app/components/Automations/Form.vue +282 -0
  16. package/app/components/Automations/List.vue +370 -0
  17. package/app/components/Automations/PageGate.vue +50 -0
  18. package/app/components/Automations/SaveProblems.vue +54 -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/WhatsappComposer.vue +5 -1
  24. package/app/components/Filters/DateInput.vue +57 -0
  25. package/app/components/Filters/Editor.vue +163 -0
  26. package/app/components/Filters/Row.vue +119 -0
  27. package/app/components/Filters/Summary.vue +215 -0
  28. package/app/components/Filters/UnavailableRow.vue +74 -0
  29. package/app/components/Filters/ValueInput.vue +215 -0
  30. package/app/components/Layout/BasePage.vue +2 -0
  31. package/app/components/TemplateList/BaseTable.vue +0 -11
  32. package/app/composables/apiErrorMessages.ts +1 -0
  33. package/app/composables/automationsContext.ts +42 -0
  34. package/app/composables/composerContext.ts +0 -6
  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 +4 -7
  43. package/app/composables/useTemplateOperations.ts +8 -3
  44. package/app/middleware/automations-enabled.ts +12 -0
  45. package/app/pages/automations/[id].vue +87 -0
  46. package/app/pages/automations/index.vue +28 -0
  47. package/app/pages/automations/new.vue +53 -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 +0 -3
  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 +389 -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 +107 -1
  66. package/i18n/locales/de.ts +269 -1
  67. package/i18n/locales/en.ts +255 -1
  68. package/i18n/locales/es.ts +258 -1
  69. package/i18n/locales/fr.ts +264 -1
  70. package/i18n/locales/it.ts +260 -1
  71. package/package.json +4 -4
  72. package/app.config.ts +0 -5
@@ -0,0 +1,215 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ Condition,
4
+ FilterField,
5
+ FilterOptionValue,
6
+ FiltersReport,
7
+ } from '@dev.smartpricing/message-composer-utils/types'
8
+ import {
9
+ conditionsToFilterRows,
10
+ FILTER_OPERATOR_LABEL_KEYS,
11
+ filterCurrency,
12
+ isFilterRange,
13
+ missingFilterOptions,
14
+ rawConditionValue,
15
+ reportFilterRows,
16
+ type FilterEditableRow,
17
+ type FilterRow,
18
+ type FilterRowValue,
19
+ } from '../../utils/filters/rows'
20
+
21
+ /**
22
+ * Public (ADR 0018): a list of Conditions, read-only, one line each.
23
+ * Props-only, as `FiltersEditor`, and it leaves no Condition out: one the
24
+ * editor cannot edit shows as unavailable.
25
+ */
26
+ const props = withDefaults(
27
+ defineProps<{
28
+ modelValue: Condition[]
29
+ /** The fields a Condition can be on. */
30
+ fields: FilterField[]
31
+ /** ISO code of the currency `price` fields show; EUR when absent. */
32
+ currency?: string
33
+ /** How many lines show before "View more". */
34
+ preview?: number
35
+ testIdPrefix?: string
36
+ }>(),
37
+ { currency: undefined, preview: 3, testIdPrefix: undefined },
38
+ )
39
+
40
+ const emit = defineEmits<{ report: [report: FiltersReport] }>()
41
+
42
+ const { t, locale } = useI18n()
43
+
44
+ interface Line {
45
+ id: number
46
+ /** "Total price is at least:". */
47
+ heading: string
48
+ value: string
49
+ /** Shown in place of `value`, which then goes in a tooltip. */
50
+ count?: string
51
+ /** Option values the field does not list, struck through. */
52
+ missing: string[]
53
+ /** Why the line is an unavailable one, as a sentence. */
54
+ unavailable?: string
55
+ /** A stored value, shown as it is. */
56
+ raw: boolean
57
+ /** A field the caller did not declare: the whole line shows as stored. */
58
+ unknown: boolean
59
+ }
60
+
61
+ const fieldsByKey = computed(() => new Map(props.fields.map((field) => [field.key, field])))
62
+
63
+ const rows = computed(() => {
64
+ let id = 0
65
+ return conditionsToFilterRows(props.modelValue, props.fields, () => ++id)
66
+ })
67
+
68
+ const report = computed(() => reportFilterRows(rows.value, props.fields))
69
+ watch(report, (value) => emit('report', value), { immediate: true })
70
+
71
+ function formatValue(row: FilterEditableRow, value: FilterRowValue): string {
72
+ if (value === undefined || Array.isArray(value)) return ''
73
+ switch (row.type) {
74
+ case 'price':
75
+ return new Intl.NumberFormat(locale.value, {
76
+ style: 'currency',
77
+ currency: filterCurrency(props.currency),
78
+ }).format(value as number)
79
+ case 'number':
80
+ return new Intl.NumberFormat(locale.value).format(value as number)
81
+ case 'date':
82
+ return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeZone: 'UTC' }).format(
83
+ new Date(`${value}T00:00:00Z`),
84
+ )
85
+ default:
86
+ return String(value)
87
+ }
88
+ }
89
+
90
+ function editableLine(row: FilterEditableRow): Line {
91
+ const field = fieldsByKey.value.get(row.field)
92
+ const operator = t(`filters.operators.${FILTER_OPERATOR_LABEL_KEYS[row.operator]}`)
93
+ const line: Line = {
94
+ id: row.id,
95
+ heading: `${field?.label ?? row.field} ${operator}`,
96
+ value: '',
97
+ missing: [],
98
+ raw: false,
99
+ unknown: false,
100
+ }
101
+ if (row.operator === 'exists') return line
102
+ line.heading += ':'
103
+
104
+ if (row.type === 'select' || row.type === 'multiselect') {
105
+ const missing = missingFilterOptions(row, field)
106
+ const selected: FilterOptionValue[] = Array.isArray(row.value)
107
+ ? row.value
108
+ : row.value === undefined
109
+ ? []
110
+ : [row.value]
111
+ const labels = (field?.options ?? [])
112
+ .filter((option) => selected.includes(option.value))
113
+ .map((option) => option.label)
114
+
115
+ line.value = labels.join(', ')
116
+ line.missing = missing.map(String)
117
+ if (labels.length > 1) line.count = t('filters.selected', { count: labels.length })
118
+ return line
119
+ }
120
+
121
+ line.value = isFilterRange(row.operator)
122
+ ? `${formatValue(row, row.value)} - ${formatValue(row, row.to)}`
123
+ : formatValue(row, row.value)
124
+ return line
125
+ }
126
+
127
+ function toLine(row: FilterRow): Line | undefined {
128
+ if (row.kind === 'blank') return undefined
129
+ if (row.kind === 'editable') return editableLine(row)
130
+
131
+ const { field: fieldKey, operator, value } = row.condition
132
+ const field = fieldsByKey.value.get(fieldKey)
133
+ const operatorKey = FILTER_OPERATOR_LABEL_KEYS[operator]
134
+ return {
135
+ id: row.id,
136
+ heading:
137
+ field && operatorKey
138
+ ? `${field.label} ${t(`filters.operators.${operatorKey}`)}`
139
+ : `${fieldKey} ${operator}`,
140
+ value: rawConditionValue(value),
141
+ missing: [],
142
+ unavailable: t(`filters.unavailable.${row.reason}`),
143
+ raw: true,
144
+ unknown: !field,
145
+ }
146
+ }
147
+
148
+ const lines = computed(() => rows.value.flatMap((row) => toLine(row) ?? []))
149
+
150
+ const expanded = ref(false)
151
+ const shown = computed(() => (expanded.value ? lines.value : lines.value.slice(0, props.preview)))
152
+
153
+ const prefix = computed(() => (props.testIdPrefix ? `${props.testIdPrefix}-` : ''))
154
+ </script>
155
+
156
+ <template>
157
+ <div v-if="lines.length" class="space-y-1" :data-testid="`${prefix}filters-summary`">
158
+ <div
159
+ v-for="line in shown"
160
+ :key="line.id"
161
+ class="flex flex-wrap items-baseline gap-1"
162
+ :data-testid="`${prefix}filters-summary-item`"
163
+ >
164
+ <span class="label-sm text-muted" :class="{ 'font-mono': line.unknown }">
165
+ {{ line.heading }}
166
+ </span>
167
+ <UTooltip v-if="line.count" :text="line.value">
168
+ <span class="paragraph-sm text-highlighted">{{ line.count }}</span>
169
+ </UTooltip>
170
+ <span
171
+ v-else-if="line.value"
172
+ class="paragraph-sm text-highlighted"
173
+ :class="{ 'font-mono': line.raw }"
174
+ >
175
+ {{ line.value }}
176
+ </span>
177
+ <UTooltip
178
+ v-for="value in line.missing"
179
+ :key="value"
180
+ :text="t('filters.unavailable.option_description')"
181
+ >
182
+ <span
183
+ class="paragraph-sm text-muted line-through"
184
+ :data-testid="`${prefix}filters-summary-unavailable-option`"
185
+ >
186
+ {{ value }}
187
+ </span>
188
+ </UTooltip>
189
+ <UTooltip v-if="line.unavailable" :text="line.unavailable">
190
+ <UBadge
191
+ color="warning"
192
+ variant="subtle"
193
+ size="sm"
194
+ :data-testid="`${prefix}filters-summary-unavailable-badge`"
195
+ >
196
+ {{ t('filters.unavailable.badge') }}
197
+ </UBadge>
198
+ </UTooltip>
199
+ </div>
200
+ <UButton
201
+ v-if="lines.length > preview"
202
+ variant="link"
203
+ color="neutral"
204
+ size="xs"
205
+ class="px-0"
206
+ :label="t(expanded ? 'common.view_less' : 'common.view_more')"
207
+ :trailing-icon="expanded ? 'ph:caret-up-bold' : 'ph:caret-down-bold'"
208
+ :data-testid="`${prefix}filters-summary-toggle`"
209
+ @click="expanded = !expanded"
210
+ />
211
+ </div>
212
+ <div v-else :data-testid="`${prefix}filters-summary`">
213
+ <span class="paragraph-sm text-muted">-</span>
214
+ </div>
215
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import type { FilterField } from '@dev.smartpricing/message-composer-utils/types'
3
+ import {
4
+ FILTER_OPERATOR_LABEL_KEYS,
5
+ rawConditionValue,
6
+ type FilterUnavailableRow,
7
+ } from '../../utils/filters/rows'
8
+
9
+ /**
10
+ * Internal to the filter components: a Condition the editor cannot edit,
11
+ * locked and removable. On a field the caller did not declare it shows raw.
12
+ */
13
+ const props = defineProps<{
14
+ row: FilterUnavailableRow
15
+ field: FilterField | undefined
16
+ testId: string
17
+ }>()
18
+
19
+ const emit = defineEmits<{ remove: [] }>()
20
+
21
+ const { t } = useI18n()
22
+
23
+ const isUnknownField = computed(() => props.row.reason === 'unknown_field')
24
+
25
+ const operatorLabel = computed(() => {
26
+ const { operator } = props.row.condition
27
+ const key = FILTER_OPERATOR_LABEL_KEYS[operator]
28
+ // A stored operator the engine's vocabulary does not have shows as it is.
29
+ return isUnknownField.value || !key ? operator : t(`filters.operators.${key}`)
30
+ })
31
+
32
+ const rawValue = computed(() => rawConditionValue(props.row.condition.value))
33
+ </script>
34
+
35
+ <template>
36
+ <div class="flex gap-2" :data-testid="testId">
37
+ <div
38
+ class="flex-1 grid grid-cols-5 gap-2 items-center rounded-md border border-dashed border-muted bg-elevated/50 px-2.5 py-1.5 min-h-8"
39
+ >
40
+ <p
41
+ class="col-span-2 paragraph-sm truncate"
42
+ :class="{ 'font-mono': isUnknownField }"
43
+ :data-testid="`${testId}-field`"
44
+ >
45
+ {{ field?.label ?? row.condition.field }}
46
+ </p>
47
+ <p class="paragraph-sm truncate" :class="{ 'font-mono': isUnknownField }">
48
+ {{ operatorLabel }}
49
+ </p>
50
+ <div class="col-span-2 flex items-center justify-between gap-2 min-w-0">
51
+ <p class="paragraph-sm font-mono truncate">{{ rawValue }}</p>
52
+ <UTooltip :text="t(`filters.unavailable.${row.reason}`)">
53
+ <UBadge
54
+ :color="isUnknownField ? 'error' : 'warning'"
55
+ variant="subtle"
56
+ class="shrink-0"
57
+ :data-testid="`${testId}-badge`"
58
+ >
59
+ {{ t('filters.unavailable.badge') }}
60
+ </UBadge>
61
+ </UTooltip>
62
+ </div>
63
+ </div>
64
+ <UButton
65
+ color="error"
66
+ variant="ghost"
67
+ icon="ph:trash"
68
+ class="shrink-0"
69
+ :aria-label="t('filters.actions.remove_filter')"
70
+ :data-testid="`${testId}-delete-button`"
71
+ @click="emit('remove')"
72
+ />
73
+ </div>
74
+ </template>
@@ -0,0 +1,215 @@
1
+ <script setup lang="ts">
2
+ import type { FilterField, FilterOptionValue } from '@dev.smartpricing/message-composer-utils/types'
3
+ import {
4
+ isFilterRange,
5
+ missingFilterOptions,
6
+ type FilterEditableRow,
7
+ type FilterRowValue,
8
+ } from '../../utils/filters/rows'
9
+
10
+ /**
11
+ * Internal to the filter components: the value of one row, by field type.
12
+ * An option value the field does not list is shown as unavailable and kept
13
+ * until the user removes it (ADR 0017).
14
+ */
15
+ const props = defineProps<{
16
+ row: FilterEditableRow
17
+ field: FilterField
18
+ currency: string
19
+ testId: string
20
+ }>()
21
+
22
+ const emit = defineEmits<{
23
+ 'update:value': [value: FilterRowValue]
24
+ 'update:to': [value: FilterRowValue]
25
+ }>()
26
+
27
+ const { t } = useI18n()
28
+
29
+ const isRange = computed(() => isFilterRange(props.row.operator))
30
+ const options = computed(() => props.field.options ?? [])
31
+ const missing = computed(() => missingFilterOptions(props.row, props.field))
32
+
33
+ // Select: the missing value stays the selected item, so it shows and is not lost.
34
+ const selectItems = computed(() => [
35
+ ...options.value,
36
+ ...missing.value.map((value) => ({
37
+ value,
38
+ label: t('filters.unavailable.option'),
39
+ disabled: true,
40
+ })),
41
+ ])
42
+
43
+ const selected = computed<FilterOptionValue[]>(() =>
44
+ Array.isArray(props.row.value) ? props.row.value : [],
45
+ )
46
+ const selectedKnown = computed(() =>
47
+ selected.value.filter((value) => !missing.value.includes(value)),
48
+ )
49
+ const selectedLabels = computed(() =>
50
+ options.value
51
+ .filter((option) => selectedKnown.value.includes(option.value))
52
+ .map((option) => option.label),
53
+ )
54
+ const selectionLabel = computed(() =>
55
+ selectedLabels.value.length === 1
56
+ ? selectedLabels.value[0]
57
+ : t('filters.selected', { count: selectedLabels.value.length }),
58
+ )
59
+
60
+ function onMultiselect(values: FilterOptionValue[]) {
61
+ emit('update:value', [...values, ...missing.value])
62
+ }
63
+
64
+ function removeMissing(value: FilterOptionValue) {
65
+ emit(
66
+ 'update:value',
67
+ selected.value.filter((item) => item !== value),
68
+ )
69
+ }
70
+
71
+ const numberFormat = computed<Intl.NumberFormatOptions | undefined>(() =>
72
+ props.field.type === 'price'
73
+ ? { style: 'currency', currency: props.currency, currencyDisplay: 'narrowSymbol' }
74
+ : undefined,
75
+ )
76
+
77
+ function asNumber(value: FilterRowValue): number | undefined {
78
+ return typeof value === 'number' ? value : undefined
79
+ }
80
+
81
+ function asString(value: FilterRowValue): string | undefined {
82
+ return typeof value === 'string' ? value : undefined
83
+ }
84
+ </script>
85
+
86
+ <template>
87
+ <div v-if="row.operator === 'exists'" />
88
+
89
+ <USelectMenu
90
+ v-else-if="field.type === 'select'"
91
+ :model-value="row.value as FilterOptionValue | undefined"
92
+ :items="selectItems"
93
+ value-key="value"
94
+ placeholder="&nbsp;"
95
+ class="w-full"
96
+ :ui="{ content: 'min-w-fit' }"
97
+ :search-input="{ placeholder: t('common.search') }"
98
+ :data-testid="`${testId}-select`"
99
+ @update:model-value="emit('update:value', $event)"
100
+ >
101
+ <UBadge
102
+ v-if="missing.length"
103
+ color="warning"
104
+ variant="outline"
105
+ class="border border-dashed ring-0 truncate"
106
+ :data-testid="`${testId}-unavailable-option`"
107
+ >
108
+ {{ t('filters.unavailable.option') }}
109
+ </UBadge>
110
+ </USelectMenu>
111
+
112
+ <div v-else-if="field.type === 'multiselect'" class="flex flex-col gap-1">
113
+ <USelectMenu
114
+ :model-value="selectedKnown"
115
+ multiple
116
+ :items="options"
117
+ value-key="value"
118
+ placeholder="&nbsp;"
119
+ class="w-full"
120
+ virtualize
121
+ :reset-search-term-on-select="false"
122
+ :ui="{ content: 'min-w-fit' }"
123
+ :search-input="{
124
+ placeholder: t('common.search'),
125
+ 'data-testid': `${testId}-multiselect-search`,
126
+ }"
127
+ :data-testid="`${testId}-multiselect`"
128
+ @update:model-value="onMultiselect"
129
+ >
130
+ <UTooltip v-if="selectedLabels.length" :text="selectedLabels.join(', ')">
131
+ <UBadge color="secondary" class="truncate">
132
+ {{ selectionLabel }}
133
+ </UBadge>
134
+ </UTooltip>
135
+ </USelectMenu>
136
+
137
+ <div v-if="missing.length" class="flex flex-wrap gap-1">
138
+ <UTooltip
139
+ v-for="value in missing"
140
+ :key="value"
141
+ :text="t('filters.unavailable.option_description')"
142
+ >
143
+ <UBadge
144
+ color="warning"
145
+ variant="outline"
146
+ class="border border-dashed ring-0 gap-1"
147
+ :data-testid="`${testId}-unavailable-option`"
148
+ >
149
+ {{ t('filters.unavailable.option') }}
150
+ <UButton
151
+ color="warning"
152
+ variant="link"
153
+ size="xs"
154
+ icon="ph:x-bold"
155
+ class="p-0"
156
+ :aria-label="t('filters.actions.remove_option')"
157
+ :data-testid="`${testId}-unavailable-option-remove`"
158
+ @click="removeMissing(value)"
159
+ />
160
+ </UBadge>
161
+ </UTooltip>
162
+ </div>
163
+ </div>
164
+
165
+ <div
166
+ v-else-if="field.type === 'number' || field.type === 'price'"
167
+ class="flex items-center gap-2"
168
+ >
169
+ <UInputNumber
170
+ :model-value="asNumber(row.value)"
171
+ class="w-full"
172
+ :format-options="numberFormat"
173
+ :increment="!isRange"
174
+ :decrement="!isRange"
175
+ :data-testid="isRange ? `${testId}-from-input` : `${testId}-input`"
176
+ @update:model-value="emit('update:value', $event ?? undefined)"
177
+ />
178
+ <template v-if="isRange">
179
+ <p class="label-xs">{{ t('filters.and') }}</p>
180
+ <UInputNumber
181
+ :model-value="asNumber(row.to)"
182
+ class="w-full"
183
+ :format-options="numberFormat"
184
+ :increment="false"
185
+ :decrement="false"
186
+ :data-testid="`${testId}-to-input`"
187
+ @update:model-value="emit('update:to', $event ?? undefined)"
188
+ />
189
+ </template>
190
+ </div>
191
+
192
+ <div v-else-if="field.type === 'date'" class="flex items-center gap-2">
193
+ <FiltersDateInput
194
+ :model-value="asString(row.value)"
195
+ :test-id="isRange ? `${testId}-from` : testId"
196
+ @update:model-value="emit('update:value', $event)"
197
+ />
198
+ <template v-if="isRange">
199
+ <p class="label-xs">{{ t('filters.and') }}</p>
200
+ <FiltersDateInput
201
+ :model-value="asString(row.to)"
202
+ :test-id="`${testId}-to`"
203
+ @update:model-value="emit('update:to', $event)"
204
+ />
205
+ </template>
206
+ </div>
207
+
208
+ <UInput
209
+ v-else
210
+ :model-value="asString(row.value)"
211
+ class="w-full"
212
+ :data-testid="`${testId}-text`"
213
+ @update:model-value="emit('update:value', $event === '' ? undefined : String($event))"
214
+ />
215
+ </template>
@@ -11,6 +11,7 @@ const props = defineProps<{
11
11
 
12
12
  const emit = defineEmits<{
13
13
  tabChange: [value: string | number]
14
+ back: []
14
15
  }>()
15
16
 
16
17
  watchEffect(() => {
@@ -28,6 +29,7 @@ watchEffect(() => {
28
29
  :data-testid="testId"
29
30
  class="bg-white sticky top-0 z-10"
30
31
  @tab-change="emit('tabChange', $event)"
32
+ @back="emit('back')"
31
33
  >
32
34
  <template #actions>
33
35
  <div class="flex items-center gap-1.5">
@@ -322,17 +322,6 @@ const paginationSummary = computed(() => {
322
322
  v-model:sorting="sorting"
323
323
  :data-testid="templateListTableTestIds.table"
324
324
  >
325
- <!-- Shown instead of the "no data" row while a list with no rows is loading. -->
326
- <template #loading>
327
- <div
328
- class="flex items-center justify-center gap-2 text-muted"
329
- :data-testid="templateListTableTestIds.loading"
330
- >
331
- <UIcon name="ph:circle-notch-bold" class="size-4 animate-spin" />
332
- <span>{{ t('pages.message_library.loading') }}</span>
333
- </div>
334
- </template>
335
-
336
325
  <template #name-cell="{ row }">
337
326
  <div
338
327
  class="flex items-center gap-2"
@@ -1,6 +1,7 @@
1
1
  const CODE_TO_KEY: Record<string, string> = {
2
2
  'session.missing': 'common.errors.session_expired',
3
3
  'session.expired': 'common.errors.session_expired',
4
+ AUTOMATION_ENGINE_REJECTED: 'automations.toast.engine_rejected',
4
5
  }
5
6
 
6
7
  function descriptionKeyByStatus(status: number): string {
@@ -0,0 +1,42 @@
1
+ import type { InjectionKey, Ref } from 'vue'
2
+
3
+ /** A value is what a Condition stores: a number where the Product's data holds one. */
4
+ export interface AutomationOption {
5
+ value: string | number
6
+ label: string
7
+ }
8
+
9
+ /**
10
+ * What a Host supplies to the Automation pages (ADR 0017), apart from
11
+ * `ComposerContext`. The Host fetches the lists and keeps them current.
12
+ */
13
+ export interface AutomationsContext {
14
+ /**
15
+ * The option lists, by the name the Automation Catalog gives them
16
+ * (`option_list` of a Condition Field). A list that is not there shows as
17
+ * empty.
18
+ */
19
+ optionLists: Ref<Record<string, AutomationOption[]>>
20
+ /** ISO code of the currency `price` fields show; EUR when absent. */
21
+ currency?: Ref<string | undefined>
22
+ }
23
+
24
+ export const AUTOMATIONS_CONTEXT_KEY: InjectionKey<AutomationsContext> = Symbol.for(
25
+ 'composer-automations-context',
26
+ )
27
+
28
+ /** Must be called in parent's app.vue or plugin before an Automation page renders. */
29
+ export function defineAutomationsContext(context: AutomationsContext): void {
30
+ // App-level provide, as `defineComposerContext`.
31
+ useNuxtApp().vueApp.provide(AUTOMATIONS_CONTEXT_KEY, context)
32
+ }
33
+
34
+ /**
35
+ * `undefined` when the Host provided none: an Automation page shows that as
36
+ * an error of its own (ADR 0019), so this does not throw.
37
+ */
38
+ export function useAutomationsContext(): AutomationsContext | undefined {
39
+ if (import.meta.server) return undefined
40
+
41
+ return useNuxtApp().vueApp.runWithContext(() => inject(AUTOMATIONS_CONTEXT_KEY, undefined))
42
+ }
@@ -22,12 +22,6 @@ export interface ComposerContext {
22
22
  * the Organization's (ADR 0015).
23
23
  */
24
24
  metaWabaId?: Ref<string>
25
- /**
26
- * The Host works on `metaWabaId` only (Smartconnect): a new Message Group
27
- * is submitted to it and the user is not asked. Applies while `metaWabaId`
28
- * is one of the Organization's WABAs; otherwise the choice stays the user's.
29
- */
30
- metaWabaLocked?: Ref<boolean>
31
25
  isAdmin?: Ref<boolean>
32
26
  organizationData?: Ref<OrganizationData>
33
27
  }
@@ -0,0 +1,33 @@
1
+ import type { Automation } from '@dev.smartpricing/message-composer-utils/types'
2
+ import AutomationsCloneModal from '~/components/Automations/CloneModal.vue'
3
+ import { useCloneAutomationMutation } from '~/queries'
4
+
5
+ /**
6
+ * Duplicating an Automation, from wherever it is offered: the modal that asks
7
+ * the name of the copy, the clone, and the copy opened on its edit page.
8
+ * Called once, in the setup of the component that offers it.
9
+ */
10
+ export function useAutomationClone() {
11
+ const { t } = useI18n()
12
+ const toast = useToast()
13
+ const { trackEvent } = useTracking()
14
+ const cloneModal = useOverlay().create(AutomationsCloneModal, { destroyOnClose: true })
15
+ const { mutateAsync: cloneAutomation } = useCloneAutomationMutation()
16
+
17
+ /** Asks the name of the copy, then makes it from the Automation as stored. */
18
+ function askClone(automation: Pick<Automation, 'id' | 'name'>) {
19
+ cloneModal.open({
20
+ defaultName: t('common.copy_of', { name: automation.name }),
21
+ action: async (name) => {
22
+ const copy = await cloneAutomation({ id: automation.id, name })
23
+ trackEvent('automation.clone', { automation_name: name, source_id: automation.id })
24
+ toast.add({ title: t('automations.toast.cloned'), color: 'success' })
25
+
26
+ // In demo mode the answer is not an Automation: there is no copy to open.
27
+ if (copy?.id) await navigateTo({ name: 'automation-edit', params: { id: copy.id } })
28
+ },
29
+ })
30
+ }
31
+
32
+ return { askClone }
33
+ }