@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.
Files changed (71) 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 +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/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/automationsContext.ts +42 -0
  33. package/app/composables/composerContext.ts +0 -6
  34. package/app/composables/useAutomationClone.ts +33 -0
  35. package/app/composables/useAutomationEditorTrip.ts +77 -0
  36. package/app/composables/useAutomationSaveProblems.ts +82 -0
  37. package/app/composables/useAutomationUnavailable.ts +98 -0
  38. package/app/composables/useAutomationsGate.ts +68 -0
  39. package/app/composables/useBuiltInOptionLists.ts +44 -0
  40. package/app/composables/useEditorTripBack.ts +15 -0
  41. package/app/composables/useMessageGroupWaba.ts +4 -7
  42. package/app/composables/useTemplateOperations.ts +8 -3
  43. package/app/middleware/automations-enabled.ts +12 -0
  44. package/app/pages/automations/[id].vue +85 -0
  45. package/app/pages/automations/index.vue +27 -0
  46. package/app/pages/automations/new.vue +51 -0
  47. package/app/pages/template/email/[id].vue +5 -0
  48. package/app/pages/template/email/create.vue +5 -0
  49. package/app/pages/template/whatsapp/[id].vue +5 -0
  50. package/app/pages/template/whatsapp/create.vue +5 -0
  51. package/app/queries/automations.ts +206 -0
  52. package/app/queries/index.ts +1 -0
  53. package/app/queries/messageGroups.ts +1 -3
  54. package/app/stores/appContext.ts +0 -3
  55. package/app/stores/editorTrip.ts +67 -0
  56. package/app/types/app-config.d.ts +3 -0
  57. package/app/types/tracking.ts +33 -0
  58. package/app/utils/automations/form.ts +368 -0
  59. package/app/utils/automations/metadata.ts +71 -0
  60. package/app/utils/automations/unavailable.ts +90 -0
  61. package/app/utils/filters/data/countries.json +998 -0
  62. package/app/utils/filters/data/languages.json +754 -0
  63. package/app/utils/filters/rows.ts +367 -0
  64. package/app/utils/testIds.const.ts +0 -1
  65. package/i18n/locales/de.ts +268 -1
  66. package/i18n/locales/en.ts +254 -1
  67. package/i18n/locales/es.ts +257 -1
  68. package/i18n/locales/fr.ts +263 -1
  69. package/i18n/locales/it.ts +259 -1
  70. package/package.json +4 -4
  71. 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
+ }
@@ -9,7 +9,7 @@ import WabaSelectModal from '~/components/Composer/WabaSelectModal.vue'
9
9
  */
10
10
  export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null | undefined>) {
11
11
  const { data: wabas, refresh } = useOrganizationWabasQuery()
12
- const { metaWabaId, metaWabaLocked } = storeToRefs(useAppContextStore())
12
+ const { metaWabaId } = storeToRefs(useAppContextStore())
13
13
  const modal = useOverlay().create(WabaSelectModal)
14
14
 
15
15
  // A submission of one language does not read the Message Group again: its
@@ -45,10 +45,9 @@ export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null
45
45
  /**
46
46
  * The WABA to name in a submission. Asks the user only at the first
47
47
  * submission of a Message Group whose Organization has several WABAs;
48
- * otherwise names none and the backend resolves it. A Host that works on
49
- * its own WABA only (`metaWabaLocked`) is not asked: that WABA is named.
50
- * A lookup that has not answered yet is waited for; one that failed leaves
51
- * it to the backend. Returns null when the user gives up.
48
+ * otherwise names none and the backend resolves it. A lookup that has not
49
+ * answered yet is waited for; one that failed leaves it to the backend.
50
+ * Returns null when the user gives up.
52
51
  */
53
52
  async function chooseWaba(): Promise<{ wabaId?: string } | null> {
54
53
  if (wabaId.value) return {}
@@ -59,8 +58,6 @@ export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null
59
58
  const preselected = candidates.some((candidate) => candidate.id === metaWabaId.value)
60
59
  ? metaWabaId.value
61
60
  : undefined
62
- if (preselected && metaWabaLocked.value) return { wabaId: preselected }
63
-
64
61
  const chosen = await modal.open({ wabas: candidates, preselected }).result
65
62
 
66
63
  return chosen ? { wabaId: chosen } : null
@@ -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,85 @@
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')">
25
+ <template #actions>
26
+ <UButton
27
+ variant="ghost"
28
+ size="lg"
29
+ :label="t('common.actions.cancel')"
30
+ @click="goBackOrFallback({ name: 'automations-list' })"
31
+ />
32
+ <UButton
33
+ v-if="edit"
34
+ size="lg"
35
+ :loading="edit.saving"
36
+ :label="t('common.actions.save')"
37
+ @click="edit.submit()"
38
+ />
39
+ </template>
40
+
41
+ <AutomationsPageGate v-slot="{ catalog, context }" :gate="gate">
42
+ <AutomationsEdit
43
+ v-if="automation"
44
+ ref="edit"
45
+ :key="automation.id"
46
+ :catalog="catalog"
47
+ :automation="automation"
48
+ :context="context"
49
+ />
50
+ <UAlert
51
+ v-else-if="status === 'error'"
52
+ color="error"
53
+ variant="subtle"
54
+ icon="i-lucide-alert-triangle"
55
+ :title="
56
+ notFound ? t('automations.gate.not_found_title') : t('automations.gate.load_failed_title')
57
+ "
58
+ :description="
59
+ notFound
60
+ ? t('automations.gate.not_found_description')
61
+ : t('automations.gate.load_failed_description')
62
+ "
63
+ :actions="
64
+ notFound
65
+ ? undefined
66
+ : [
67
+ {
68
+ label: t('common.organization.retry'),
69
+ color: 'error',
70
+ variant: 'outline',
71
+ onClick: () => void refetch(),
72
+ },
73
+ ]
74
+ "
75
+ />
76
+ <div
77
+ v-else
78
+ class="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-muted"
79
+ role="status"
80
+ >
81
+ <UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8" />
82
+ </div>
83
+ </AutomationsPageGate>
84
+ </LayoutBasePage>
85
+ </template>
@@ -0,0 +1,27 @@
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')">
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
+ />
21
+ </template>
22
+
23
+ <AutomationsPageGate v-slot="{ catalog }" :gate="gate">
24
+ <AutomationsList :catalog="catalog" />
25
+ </AutomationsPageGate>
26
+ </LayoutBasePage>
27
+ </template>
@@ -0,0 +1,51 @@
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')">
16
+ <template #actions>
17
+ <UButton
18
+ variant="ghost"
19
+ size="lg"
20
+ :label="t('common.actions.cancel')"
21
+ @click="goBackOrFallback({ name: 'automations-list' })"
22
+ />
23
+ <UButton
24
+ v-if="create"
25
+ size="lg"
26
+ :loading="create.saving"
27
+ :label="t('automations.form.create')"
28
+ @click="create.submit()"
29
+ />
30
+ </template>
31
+
32
+ <AutomationsPageGate v-slot="{ catalog, context }" :gate="gate">
33
+ <AutomationsCreate
34
+ v-if="catalog.triggers[0]"
35
+ ref="create"
36
+ :catalog="catalog"
37
+ :initial-trigger="catalog.triggers[0]"
38
+ :context="context"
39
+ />
40
+ <!-- A catalog with no Trigger has nothing to build an Automation from. -->
41
+ <UAlert
42
+ v-else
43
+ color="error"
44
+ variant="subtle"
45
+ icon="i-lucide-alert-triangle"
46
+ :title="t('automations.gate.no_catalog_title')"
47
+ :description="t('automations.gate.no_catalog_description')"
48
+ />
49
+ </AutomationsPageGate>
50
+ </LayoutBasePage>
51
+ </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>