@dev.smartpricing/message-composer-layer 4.5.1-next.3 → 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 (73) 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 +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/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/apiErrorMessages.ts +1 -0
  34. package/app/composables/automationsContext.ts +42 -0
  35. package/app/composables/composerContext.ts +3 -3
  36. package/app/composables/useAutomationClone.ts +33 -0
  37. package/app/composables/useAutomationEditorTrip.ts +77 -0
  38. package/app/composables/useAutomationSaveProblems.ts +82 -0
  39. package/app/composables/useAutomationUnavailable.ts +98 -0
  40. package/app/composables/useAutomationsGate.ts +68 -0
  41. package/app/composables/useBuiltInOptionLists.ts +44 -0
  42. package/app/composables/useEditorTripBack.ts +15 -0
  43. package/app/composables/useMessageGroupWaba.ts +7 -10
  44. package/app/composables/useTemplateOperations.ts +8 -3
  45. package/app/middleware/automations-enabled.ts +12 -0
  46. package/app/pages/automations/[id].vue +87 -0
  47. package/app/pages/automations/index.vue +28 -0
  48. package/app/pages/automations/new.vue +53 -0
  49. package/app/pages/template/email/[id].vue +5 -0
  50. package/app/pages/template/email/create.vue +5 -0
  51. package/app/pages/template/whatsapp/[id].vue +5 -0
  52. package/app/pages/template/whatsapp/create.vue +5 -0
  53. package/app/queries/automations.ts +206 -0
  54. package/app/queries/index.ts +1 -0
  55. package/app/queries/messageGroups.ts +1 -3
  56. package/app/stores/appContext.ts +1 -1
  57. package/app/stores/editorTrip.ts +67 -0
  58. package/app/types/app-config.d.ts +3 -0
  59. package/app/types/tracking.ts +33 -0
  60. package/app/utils/automations/form.ts +389 -0
  61. package/app/utils/automations/metadata.ts +71 -0
  62. package/app/utils/automations/unavailable.ts +90 -0
  63. package/app/utils/filters/data/countries.json +998 -0
  64. package/app/utils/filters/data/languages.json +754 -0
  65. package/app/utils/filters/rows.ts +367 -0
  66. package/app/utils/testIds.const.ts +107 -1
  67. package/i18n/locales/de.ts +269 -1
  68. package/i18n/locales/en.ts +255 -1
  69. package/i18n/locales/es.ts +258 -1
  70. package/i18n/locales/fr.ts +264 -1
  71. package/i18n/locales/it.ts +260 -1
  72. package/package.json +4 -4
  73. package/app.config.ts +0 -5
@@ -0,0 +1,77 @@
1
+ import type { Automation } from '@dev.smartpricing/message-composer-utils/types'
2
+ import { EDITOR_ROUTES } from '~/stores/editorTrip'
3
+ import {
4
+ cloneFormData,
5
+ type AutomationFormAction,
6
+ type AutomationFormState,
7
+ } from '~/utils/automations/form'
8
+
9
+ /** The Action in the Action editor. */
10
+ export interface OpenAutomationAction {
11
+ action: AutomationFormAction
12
+ /** Whether the form does not hold it yet. */
13
+ isNew: boolean
14
+ }
15
+
16
+ /** A request to leave for the Message editor: a new Message Group, or the Action's own. */
17
+ export interface AutomationMessageRequest extends OpenAutomationAction {
18
+ target: 'create' | 'edit'
19
+ }
20
+
21
+ /** What an Automation page wants back from an editor trip. */
22
+ interface AutomationTripKept {
23
+ /** The unsaved Automation. */
24
+ form: AutomationFormState
25
+ /** The Automation as the Automation Engine holds it; none on the create page. */
26
+ stored?: Automation
27
+ openAction: OpenAutomationAction
28
+ }
29
+
30
+ /**
31
+ * The Automation side of an editor trip: leaving for the Message editor with
32
+ * the unsaved Automation kept, and getting it back on return with the Action
33
+ * reopened and the Message Group just saved selected in it.
34
+ *
35
+ * Called once, in the setup of the component that holds the form: a trip that
36
+ * started on `origin` is consumed there.
37
+ */
38
+ export function useAutomationEditorTrip(
39
+ origin: 'automation-create' | 'automation-edit',
40
+ automationId?: string,
41
+ ) {
42
+ const { t } = useI18n()
43
+ const toast = useToast()
44
+ const editorTrip = useEditorTripStore()
45
+
46
+ const trip = editorTrip.consume<AutomationTripKept>(origin)
47
+ // The edit page of another Automation is not where the trip started.
48
+ const returned = trip && trip.kept.stored?.id === automationId ? trip : undefined
49
+
50
+ let restored: AutomationTripKept | undefined
51
+ if (returned) {
52
+ const { kept, result } = returned
53
+ // Only the Message Group changes: an Action's status is the user's to set.
54
+ const action = result
55
+ ? { ...kept.openAction.action, messageGroupId: result.id, messageGroupName: result.name }
56
+ : kept.openAction.action
57
+ restored = { ...kept, openAction: { ...kept.openAction, action } }
58
+ toast.add({ title: t('automations.trip.kept'), color: 'info' })
59
+ }
60
+
61
+ /** Leaves for the Message editor, keeping the unsaved Automation and the open Action. */
62
+ function leave(
63
+ unsaved: { form: AutomationFormState; stored?: Automation },
64
+ { action, isNew, target }: AutomationMessageRequest,
65
+ ) {
66
+ editorTrip.start<AutomationTripKept>(
67
+ { routeName: origin, backLabel: t('automations.trip.back') },
68
+ { ...cloneFormData(unsaved), openAction: { action: cloneFormData(action), isNew } },
69
+ )
70
+ const name = EDITOR_ROUTES[action.channel][target]
71
+ return navigateTo(
72
+ target === 'edit' ? { name, params: { id: action.messageGroupId } } : { name },
73
+ )
74
+ }
75
+
76
+ return { restored, leave }
77
+ }
@@ -0,0 +1,82 @@
1
+ import type {
2
+ AutomationCatalog,
3
+ AutomationSaveProblem,
4
+ } from '@dev.smartpricing/message-composer-utils/types'
5
+ import type { MaybeRefOrGetter } from 'vue'
6
+ import type { AutomationFormState } from '~/utils/automations/form'
7
+
8
+ /** One line of the alert: what is wrong and, when it has one, the place to jump to. */
9
+ export interface AutomationProblemLine {
10
+ text: string
11
+ /** The id of the element the line is about. */
12
+ target?: string
13
+ actionKey?: string
14
+ }
15
+
16
+ /**
17
+ * What a refused save (422 `AUTOMATION_ACTIONS_INVALID`) says at the top of
18
+ * the Automation form, on the create page and on the edit page alike.
19
+ * `AutomationsSaveProblems` renders the lines.
20
+ */
21
+ export function useAutomationSaveProblems(catalog: MaybeRefOrGetter<AutomationCatalog>) {
22
+ const { t, te } = useI18n()
23
+
24
+ const lines = ref<AutomationProblemLine[]>([])
25
+ const invalidActionKeys = computed(() =>
26
+ lines.value.flatMap(({ actionKey }) => (actionKey ? [actionKey] : [])),
27
+ )
28
+
29
+ /**
30
+ * The line of one problem. A problem points at an Action by its position
31
+ * in what was submitted; `undefined` for a problem this version does not
32
+ * know.
33
+ */
34
+ function toLine(
35
+ problem: AutomationSaveProblem,
36
+ sent: AutomationFormState,
37
+ ): AutomationProblemLine | undefined {
38
+ if (problem.target === 'trigger') {
39
+ return {
40
+ text: t('automations.problems.trigger_not_in_catalog'),
41
+ target: 'automation-trigger',
42
+ }
43
+ }
44
+
45
+ if (problem.target === 'condition') {
46
+ const key = `automations.problems.condition_${problem.reason}`
47
+ const label =
48
+ toValue(catalog)
49
+ .triggers.find(({ type }) => type === sent.triggerType)
50
+ ?.condition_fields.find((field) => field.key === problem.field)?.label ?? problem.field
51
+ return te(key)
52
+ ? { text: t(key, { field: label }), target: 'automation-conditions' }
53
+ : undefined
54
+ }
55
+
56
+ if (problem.target === 'action') {
57
+ const key = `automations.problems.action_${problem.reason}`
58
+ const action = sent.actions[problem.position]
59
+ if (!action || !te(key)) return undefined
60
+ return {
61
+ text: t(key, { name: action.messageGroupName }),
62
+ target: `automation-action-${action.key}`,
63
+ actionKey: action.key,
64
+ }
65
+ }
66
+
67
+ return undefined
68
+ }
69
+
70
+ /** `sent` is the form as it was submitted: the problems point into it. */
71
+ function show(problems: AutomationSaveProblem[], sent: AutomationFormState) {
72
+ const known = problems.flatMap((problem) => toLine(problem, sent) ?? [])
73
+ // Refused for reasons this version cannot name: still say it was refused.
74
+ lines.value = known.length > 0 ? known : [{ text: t('automations.problems.unknown') }]
75
+ }
76
+
77
+ function clear() {
78
+ lines.value = []
79
+ }
80
+
81
+ return { lines, invalidActionKeys, show, clear }
82
+ }
@@ -0,0 +1,98 @@
1
+ import type { FilterField, FiltersReport } from '@dev.smartpricing/message-composer-utils/types'
2
+ import type { AutomationFormAction } from '~/utils/automations/form'
3
+ import {
4
+ actionUnavailableKinds,
5
+ unavailableBlocks,
6
+ type ActionAvailability,
7
+ type AutomationUnavailableItem,
8
+ type AutomationUnavailableKind,
9
+ } from '~/utils/automations/unavailable'
10
+
11
+ export const AUTOMATION_TRIGGER_ELEMENT_ID = 'automation-trigger'
12
+ export const AUTOMATION_CONDITIONS_ELEMENT_ID = 'automation-conditions'
13
+
14
+ export function automationActionElementId(actionKey: string): string {
15
+ return `automation-action-${actionKey}`
16
+ }
17
+
18
+ /**
19
+ * The unavailable things of an Automation as the form says them: one item
20
+ * each, with where it is, its sentence and whether it blocks the save.
21
+ * `AutomationsUnavailable` lists them, `AutomationsUnavailableBadge` marks
22
+ * one in place.
23
+ */
24
+ export function useAutomationUnavailable() {
25
+ const { t } = useI18n()
26
+
27
+ function item(
28
+ kind: AutomationUnavailableKind,
29
+ rest: Omit<AutomationUnavailableItem, 'kind' | 'blocks' | 'text'>,
30
+ params: Record<string, string> = {},
31
+ ): AutomationUnavailableItem {
32
+ return {
33
+ kind,
34
+ blocks: unavailableBlocks(kind),
35
+ text: t(`automations.unavailable.${kind}`, params),
36
+ ...rest,
37
+ }
38
+ }
39
+
40
+ /** A Trigger the Automation Catalog no longer lists. */
41
+ function triggerItem(): AutomationUnavailableItem {
42
+ return item('trigger', {
43
+ key: 'trigger',
44
+ where: t('automations.form.trigger'),
45
+ target: AUTOMATION_TRIGGER_ELEMENT_ID,
46
+ })
47
+ }
48
+
49
+ /** What the filter components report on a list of Conditions (ADR 0018). */
50
+ function conditionItems(
51
+ report: FiltersReport | undefined,
52
+ fields: readonly FilterField[],
53
+ ): AutomationUnavailableItem[] {
54
+ if (!report) return []
55
+
56
+ const where = (field: string) =>
57
+ t('automations.unavailable.where.filter', {
58
+ field: fields.find(({ key }) => key === field)?.label ?? field,
59
+ })
60
+ const place = { target: AUTOMATION_CONDITIONS_ELEMENT_ID, inFilters: true }
61
+
62
+ return [
63
+ ...report.unavailable.map(({ condition, reason }, index) =>
64
+ item(reason === 'unknown_field' ? 'condition_unknown_field' : 'condition_not_editable', {
65
+ key: `condition-${index}`,
66
+ where: where(condition.field),
67
+ ...place,
68
+ }),
69
+ ),
70
+ ...report.unavailableOptions.map(({ field }, index) =>
71
+ item('option', { key: `option-${index}`, where: where(field), ...place }),
72
+ ),
73
+ ]
74
+ }
75
+
76
+ function actionItems(
77
+ action: AutomationFormAction,
78
+ availability: ActionAvailability,
79
+ ): AutomationUnavailableItem[] {
80
+ const channel = t(`automations.channels.${action.channel}`)
81
+
82
+ return actionUnavailableKinds(action, availability).map((kind) =>
83
+ item(
84
+ kind,
85
+ {
86
+ key: `${action.key}-${kind}`,
87
+ where: t('automations.unavailable.where.message', {
88
+ name: action.messageGroupName || channel,
89
+ }),
90
+ target: automationActionElementId(action.key),
91
+ },
92
+ { channel },
93
+ ),
94
+ )
95
+ }
96
+
97
+ return { triggerItem, conditionItems, actionItems }
98
+ }
@@ -0,0 +1,68 @@
1
+ import type { AutomationCatalog } from '@dev.smartpricing/message-composer-utils/types'
2
+ import type { AutomationsContext } from './automationsContext'
3
+
4
+ export type AutomationsGate =
5
+ | { state: 'ready'; catalog: AutomationCatalog; context: AutomationsContext }
6
+ | { state: 'loading' }
7
+ | { state: 'error'; title: string; description: string; retry?: () => void }
8
+
9
+ /**
10
+ * What an Automation page needs before it can show anything: the Host's
11
+ * Automations context, an Organization and an Automation Catalog for the
12
+ * Host's Product. Checked by the page, not by the layout, so a Host that
13
+ * replaces the layout keeps it (ADR 0019). `AutomationsPageGate` renders it.
14
+ */
15
+ export function useAutomationsGate() {
16
+ const { t } = useI18n()
17
+ const context = useAutomationsContext()
18
+ const organization = useEffectiveOrganizationStore()
19
+ const catalogQuery = useAutomationCatalogQuery()
20
+ const { product } = storeToRefs(useAppContextStore())
21
+
22
+ return computed<AutomationsGate>(() => {
23
+ if (!context) {
24
+ return {
25
+ state: 'error',
26
+ title: t('automations.gate.no_context_title'),
27
+ description: t('automations.gate.no_context_description'),
28
+ }
29
+ }
30
+
31
+ if (organization.status === 'resolving') return { state: 'loading' }
32
+ if (organization.status === 'failed') {
33
+ return {
34
+ state: 'error',
35
+ title: t('common.organization.lookup_failed_title'),
36
+ description: t('common.organization.lookup_failed_description'),
37
+ retry: () => void organization.retry(),
38
+ }
39
+ }
40
+ if (organization.status === 'none') {
41
+ return {
42
+ state: 'error',
43
+ title: t('automations.gate.no_organization_title'),
44
+ description: t('automations.gate.no_organization_description'),
45
+ }
46
+ }
47
+
48
+ const response = catalogQuery.data.value
49
+ if (response?.catalog) return { state: 'ready', catalog: response.catalog, context }
50
+ // Answered with no catalog, or nothing to ask for: no Product, no request.
51
+ if (response || !product.value) {
52
+ return {
53
+ state: 'error',
54
+ title: t('automations.gate.no_catalog_title'),
55
+ description: t('automations.gate.no_catalog_description'),
56
+ }
57
+ }
58
+ if (catalogQuery.status.value === 'error') {
59
+ return {
60
+ state: 'error',
61
+ title: t('automations.gate.catalog_failed_title'),
62
+ description: t('automations.gate.catalog_failed_description'),
63
+ retry: () => void catalogQuery.refetch(),
64
+ }
65
+ }
66
+ return { state: 'loading' }
67
+ })
68
+ }
@@ -0,0 +1,44 @@
1
+ import type { FilterOption } from '@dev.smartpricing/message-composer-utils/types'
2
+ import countries from '../utils/filters/data/countries.json'
3
+ import languages from '../utils/filters/data/languages.json'
4
+
5
+ /** The name of a code in a language, or the English one where `Intl` has none. */
6
+ function toOptions(
7
+ entries: { code: string; name: string }[],
8
+ type: 'region' | 'language',
9
+ locale: string,
10
+ ): FilterOption[] {
11
+ let names: Intl.DisplayNames | undefined
12
+ try {
13
+ names = new Intl.DisplayNames([locale], { type, fallback: 'none' })
14
+ } catch {
15
+ // A locale `Intl` does not know: the English names.
16
+ }
17
+
18
+ const options = entries.map(({ code, name }) => {
19
+ let label: string | undefined
20
+ try {
21
+ label = names?.of(type === 'region' ? code.toUpperCase() : code)
22
+ } catch {
23
+ // A code `Intl` rejects keeps its English name.
24
+ }
25
+ return { value: code, label: label ?? name }
26
+ })
27
+
28
+ return options.sort((a, b) => a.label.localeCompare(b.label, locale))
29
+ }
30
+
31
+ /**
32
+ * Public (ADR 0018): the sets built into the layer, as option lists in the
33
+ * current language, sorted by label. The values are lowercase ISO codes
34
+ * (`it`, `de`), the ones Smartconnect stores. For any caller of the filter
35
+ * components, and for a Host to pass on in `defineAutomationsContext`.
36
+ */
37
+ export function useBuiltInOptionLists() {
38
+ const { locale } = useI18n()
39
+
40
+ const countryOptions = computed(() => toOptions(countries, 'region', locale.value))
41
+ const languageOptions = computed(() => toOptions(languages, 'language', locale.value))
42
+
43
+ return { countries: countryOptions, languages: languageOptions }
44
+ }
@@ -0,0 +1,15 @@
1
+ import type { ButtonProps } from '#ui/types'
2
+
3
+ /**
4
+ * The back button of an editor page while an editor trip exists; none
5
+ * otherwise. It does what Cancel does, so it is busy whenever Cancel is.
6
+ */
7
+ export function useEditorTripBack(busy: MaybeRefOrGetter<boolean | undefined>) {
8
+ const editorTrip = useEditorTripStore()
9
+
10
+ return computed<ButtonProps | undefined>(() =>
11
+ editorTrip.backLabel === undefined
12
+ ? undefined
13
+ : { label: editorTrip.backLabel, loading: Boolean(toValue(busy)) },
14
+ )
15
+ }
@@ -43,10 +43,9 @@ export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null
43
43
  )
44
44
 
45
45
  /**
46
- * The WABA to name in a submission. At the first submission of a Message
47
- * Group whose Organization has several WABAs, it is the Host's when the
48
- * Host passed one of them, otherwise the user is asked. In every other
49
- * case none is named and the backend resolves it. A lookup that has not
46
+ * The WABA to name in a submission. Asks the user only at the first
47
+ * submission of a Message Group whose Organization has several WABAs;
48
+ * otherwise names none and the backend resolves it. A lookup that has not
50
49
  * answered yet is waited for; one that failed leaves it to the backend.
51
50
  * Returns null when the user gives up.
52
51
  */
@@ -56,12 +55,10 @@ export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null
56
55
  const candidates = wabas.value
57
56
  if (!candidates || candidates.length < 2) return {}
58
57
 
59
- const hostWabaId = metaWabaId.value
60
- if (hostWabaId && candidates.some((candidate) => candidate.id === hostWabaId)) {
61
- return { wabaId: hostWabaId }
62
- }
63
-
64
- const chosen = await modal.open({ wabas: candidates }).result
58
+ const preselected = candidates.some((candidate) => candidate.id === metaWabaId.value)
59
+ ? metaWabaId.value
60
+ : undefined
61
+ const chosen = await modal.open({ wabas: candidates, preselected }).result
65
62
 
66
63
  return chosen ? { wabaId: chosen } : null
67
64
  }
@@ -21,7 +21,8 @@ interface TemplateData<T = Message | PostEntity<Message>> {
21
21
  }
22
22
 
23
23
  interface UseTemplateOperationsOptions {
24
- onSuccess?: () => void
24
+ /** Called with the Message Group that was saved, before anything navigates. */
25
+ onSuccess?: (saved?: { id: string; name: string }) => void
25
26
  onError?: (error: Error) => void
26
27
  }
27
28
 
@@ -67,7 +68,11 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
67
68
  }
68
69
 
69
70
  saved.value = true
70
- options.onSuccess?.()
71
+ options.onSuccess?.(
72
+ createdMessageGroup?.id
73
+ ? { id: String(createdMessageGroup.id), name: createdMessageGroup.name }
74
+ : undefined,
75
+ )
71
76
  return createdMessageGroup
72
77
  } catch (error) {
73
78
  options.onError?.(error as Error)
@@ -101,7 +106,7 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
101
106
  }
102
107
 
103
108
  saved.value = true
104
- options.onSuccess?.()
109
+ options.onSuccess?.({ id, name: data.messageGroup.name })
105
110
  } catch (error) {
106
111
  options.onError?.(error as Error)
107
112
  throw error
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Automation pages are off until a Host sets `composer.automations`
3
+ * (ADR 0019): their routes always exist, and answer 404 while it is off.
4
+ */
5
+ export default defineNuxtRouteMiddleware(() => {
6
+ if (useAppConfig().composer?.automations) return
7
+
8
+ // Fatal, or a navigation inside the app would be cancelled without a word.
9
+ return abortNavigation(
10
+ createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true }),
11
+ )
12
+ })
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({
3
+ name: 'automation-edit',
4
+ middleware: 'automations-enabled',
5
+ })
6
+
7
+ const { t } = useI18n()
8
+ const route = useRoute()
9
+ const gate = useAutomationsGate()
10
+ const { goBackOrFallback } = useGoBack()
11
+
12
+ const {
13
+ data: automation,
14
+ error,
15
+ status,
16
+ refetch,
17
+ } = useAutomationQuery(() => String(route.params.id))
18
+ const notFound = computed(() => (error.value as { status?: number } | null)?.status === 404)
19
+
20
+ const edit = useTemplateRef('edit')
21
+ </script>
22
+
23
+ <template>
24
+ <LayoutBasePage :title="t('automations.editTitle')" :test-id="automationsTestIds.editPage">
25
+ <template #actions>
26
+ <UButton
27
+ variant="ghost"
28
+ size="lg"
29
+ :label="t('common.actions.cancel')"
30
+ :data-testid="automationsTestIds.editPageCancelButton"
31
+ @click="goBackOrFallback({ name: 'automations-list' })"
32
+ />
33
+ <UButton
34
+ v-if="edit"
35
+ size="lg"
36
+ :loading="edit.saving"
37
+ :label="t('common.actions.save')"
38
+ :data-testid="automationsTestIds.editPageCreateButton"
39
+ @click="edit.submit()"
40
+ />
41
+ </template>
42
+
43
+ <AutomationsPageGate v-slot="{ catalog, context }" :gate="gate">
44
+ <AutomationsEdit
45
+ v-if="automation"
46
+ ref="edit"
47
+ :key="automation.id"
48
+ :catalog="catalog"
49
+ :automation="automation"
50
+ :context="context"
51
+ />
52
+ <UAlert
53
+ v-else-if="status === 'error'"
54
+ color="error"
55
+ variant="subtle"
56
+ icon="i-lucide-alert-triangle"
57
+ :title="
58
+ notFound ? t('automations.gate.not_found_title') : t('automations.gate.load_failed_title')
59
+ "
60
+ :description="
61
+ notFound
62
+ ? t('automations.gate.not_found_description')
63
+ : t('automations.gate.load_failed_description')
64
+ "
65
+ :actions="
66
+ notFound
67
+ ? undefined
68
+ : [
69
+ {
70
+ label: t('common.organization.retry'),
71
+ color: 'error',
72
+ variant: 'outline',
73
+ onClick: () => void refetch(),
74
+ },
75
+ ]
76
+ "
77
+ />
78
+ <div
79
+ v-else
80
+ class="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-muted"
81
+ role="status"
82
+ >
83
+ <UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8" />
84
+ </div>
85
+ </AutomationsPageGate>
86
+ </LayoutBasePage>
87
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({
3
+ name: 'automations-list',
4
+ middleware: 'automations-enabled',
5
+ })
6
+
7
+ const { t } = useI18n()
8
+ const gate = useAutomationsGate()
9
+ </script>
10
+
11
+ <template>
12
+ <LayoutBasePage :title="t('automations.title')" :test-id="automationsTestIds.page">
13
+ <template #actions>
14
+ <CommonOrganizationSelect />
15
+ <UButton
16
+ v-if="gate.state === 'ready'"
17
+ icon="ph:plus-bold"
18
+ :label="t('automations.new')"
19
+ :to="{ name: 'automation-create' }"
20
+ :data-testid="automationsTestIds.newButton"
21
+ />
22
+ </template>
23
+
24
+ <AutomationsPageGate v-slot="{ catalog }" :gate="gate">
25
+ <AutomationsList :catalog="catalog" />
26
+ </AutomationsPageGate>
27
+ </LayoutBasePage>
28
+ </template>
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({
3
+ name: 'automation-create',
4
+ middleware: 'automations-enabled',
5
+ })
6
+
7
+ const { t } = useI18n()
8
+ const gate = useAutomationsGate()
9
+ const { goBackOrFallback } = useGoBack()
10
+
11
+ const create = useTemplateRef('create')
12
+ </script>
13
+
14
+ <template>
15
+ <LayoutBasePage :title="t('automations.newTitle')" :test-id="automationsTestIds.newPage">
16
+ <template #actions>
17
+ <UButton
18
+ variant="ghost"
19
+ size="lg"
20
+ :label="t('common.actions.cancel')"
21
+ :data-testid="automationsTestIds.newPageCancelButton"
22
+ @click="goBackOrFallback({ name: 'automations-list' })"
23
+ />
24
+ <UButton
25
+ v-if="create"
26
+ size="lg"
27
+ :loading="create.saving"
28
+ :label="t('automations.form.create')"
29
+ :data-testid="automationsTestIds.newPageCreateButton"
30
+ @click="create.submit()"
31
+ />
32
+ </template>
33
+
34
+ <AutomationsPageGate v-slot="{ catalog, context }" :gate="gate">
35
+ <AutomationsCreate
36
+ v-if="catalog.triggers[0]"
37
+ ref="create"
38
+ :catalog="catalog"
39
+ :initial-trigger="catalog.triggers[0]"
40
+ :context="context"
41
+ />
42
+ <!-- A catalog with no Trigger has nothing to build an Automation from. -->
43
+ <UAlert
44
+ v-else
45
+ color="error"
46
+ variant="subtle"
47
+ icon="i-lucide-alert-triangle"
48
+ :title="t('automations.gate.no_catalog_title')"
49
+ :description="t('automations.gate.no_catalog_description')"
50
+ />
51
+ </AutomationsPageGate>
52
+ </LayoutBasePage>
53
+ </template>
@@ -8,6 +8,9 @@ definePageMeta({
8
8
  const route = useRoute()
9
9
  const composerRef = useTemplateRef('composerRef')
10
10
  const store = useEmailComposerStore()
11
+ const tripBack = useEditorTripBack(
12
+ () => composerRef.value?.saving || composerRef.value?.isTranslating,
13
+ )
11
14
  </script>
12
15
 
13
16
  <template>
@@ -25,6 +28,8 @@ const store = useEmailComposerStore()
25
28
  ]"
26
29
  :active-tab="store.activeTab"
27
30
  :test-id="templateEmailEditTestIds.navbar"
31
+ :back="tripBack"
32
+ @back="composerRef?.handleCancel()"
28
33
  @tab-change="store.activeTab = $event as 'content' | 'translations'"
29
34
  >
30
35
  <template #actions>
@@ -7,6 +7,9 @@ definePageMeta({
7
7
 
8
8
  const composerRef = useTemplateRef('composerRef')
9
9
  const store = useEmailComposerStore()
10
+ const tripBack = useEditorTripBack(
11
+ () => composerRef.value?.saving || composerRef.value?.isTranslating,
12
+ )
10
13
  </script>
11
14
 
12
15
  <template>
@@ -24,6 +27,8 @@ const store = useEmailComposerStore()
24
27
  ]"
25
28
  :active-tab="store.activeTab"
26
29
  :test-id="templateEmailCreateTestIds.navbar"
30
+ :back="tripBack"
31
+ @back="composerRef?.handleCancel()"
27
32
  @tab-change="store.activeTab = $event as 'content' | 'translations'"
28
33
  >
29
34
  <template #actions>