@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
@@ -8,6 +8,9 @@ definePageMeta({
8
8
  const route = useRoute()
9
9
  const composerRef = useTemplateRef('composerRef')
10
10
  const store = useWhatsappComposerStore()
11
+ const tripBack = useEditorTripBack(
12
+ () => composerRef.value?.saving || composerRef.value?.isTranslating,
13
+ )
11
14
  </script>
12
15
 
13
16
  <template>
@@ -19,6 +22,8 @@ const store = useWhatsappComposerStore()
19
22
  ]"
20
23
  :active-tab="store.activeTab"
21
24
  :test-id="templateWhatsappEditTestIds.navbar"
25
+ :back="tripBack"
26
+ @back="composerRef?.handleCancel()"
22
27
  @tab-change="store.activeTab = $event as 'content' | 'translations'"
23
28
  >
24
29
  <template #actions>
@@ -7,6 +7,9 @@ definePageMeta({
7
7
 
8
8
  const composerRef = useTemplateRef('composerRef')
9
9
  const store = useWhatsappComposerStore()
10
+ const tripBack = useEditorTripBack(
11
+ () => composerRef.value?.saving || composerRef.value?.isTranslating,
12
+ )
10
13
  </script>
11
14
 
12
15
  <template>
@@ -18,6 +21,8 @@ const store = useWhatsappComposerStore()
18
21
  ]"
19
22
  :active-tab="store.activeTab"
20
23
  :test-id="templateWhatsappCreateTestIds.navbar"
24
+ :back="tripBack"
25
+ @back="composerRef?.handleCancel()"
21
26
  @tab-change="store.activeTab = $event as 'content' | 'translations'"
22
27
  >
23
28
  <template #actions>
@@ -0,0 +1,206 @@
1
+ import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
2
+ import {
3
+ AUTOMATION_CATALOG_LANGUAGES,
4
+ type Automation,
5
+ type AutomationCatalogLanguage,
6
+ type CreateAutomationBody,
7
+ } from '@dev.smartpricing/message-composer-utils/types'
8
+ import type { MaybeRefOrGetter } from 'vue'
9
+ import { toValue } from 'vue'
10
+ import { automationsApi, messageGroupsApi } from '~/api'
11
+ import type { AutomationChanges } from '~/utils/automations/form'
12
+
13
+ function isCatalogLanguage(language: string): language is AutomationCatalogLanguage {
14
+ return (AUTOMATION_CATALOG_LANGUAGES as readonly string[]).includes(language)
15
+ }
16
+
17
+ /** The Automations of the Effective Organization for the Host's Product. */
18
+ export function useAutomationsQuery() {
19
+ const { product } = storeToRefs(useAppContextStore())
20
+ const organization = useEffectiveOrganizationStore()
21
+
22
+ return useQuery({
23
+ key: () => ['automations', product.value ?? '', organization.organizationId],
24
+ query: () =>
25
+ automationsApi.getAll({
26
+ organization_id: organization.requireOrganizationId(),
27
+ product: product.value!,
28
+ }),
29
+ enabled: () => Boolean(product.value && organization.organizationId),
30
+ staleTime: 2 * 60 * 1000,
31
+ })
32
+ }
33
+
34
+ /**
35
+ * One Automation of the Host's Product, read once for the page that edits it:
36
+ * nothing is kept for the next visit, and no refetch replaces what the user
37
+ * is editing. Its key is not under the list's, so a save does not refetch it.
38
+ */
39
+ export function useAutomationQuery(id: MaybeRefOrGetter<string>) {
40
+ const { product } = storeToRefs(useAppContextStore())
41
+ const organization = useEffectiveOrganizationStore()
42
+
43
+ return useQuery({
44
+ key: () => ['automation', product.value ?? '', organization.organizationId, toValue(id)],
45
+ query: () => automationsApi.getById(toValue(id), { product: product.value! }),
46
+ enabled: () => Boolean(product.value && organization.organizationId),
47
+ staleTime: 0,
48
+ gcTime: 0,
49
+ refetchOnWindowFocus: false,
50
+ refetchOnReconnect: false,
51
+ })
52
+ }
53
+
54
+ /**
55
+ * The Automation Catalog of the Host's Product, with its labels in the
56
+ * language of the interface (ADR 0017).
57
+ */
58
+ export function useAutomationCatalogQuery() {
59
+ const { product } = storeToRefs(useAppContextStore())
60
+ const organization = useEffectiveOrganizationStore()
61
+ const { locale } = useI18n()
62
+ const language = computed(() => (isCatalogLanguage(locale.value) ? locale.value : 'en'))
63
+
64
+ return useQuery({
65
+ key: () => [
66
+ 'automation-catalog',
67
+ product.value ?? '',
68
+ language.value,
69
+ organization.organizationId,
70
+ ],
71
+ query: () =>
72
+ automationsApi.getCatalog({
73
+ organization_id: organization.requireOrganizationId(),
74
+ product: product.value!,
75
+ language: language.value,
76
+ }),
77
+ enabled: () => Boolean(product.value && organization.organizationId),
78
+ staleTime: 30 * 60 * 1000,
79
+ })
80
+ }
81
+
82
+ /** A new Automation names its Organization and its Product (ADR 0014, ADR 0019). */
83
+ export function useCreateAutomationMutation() {
84
+ const queryCache = useQueryCache()
85
+ const { product } = storeToRefs(useAppContextStore())
86
+ const organization = useEffectiveOrganizationStore()
87
+
88
+ return useMutation({
89
+ mutation: (automation: Omit<CreateAutomationBody, 'organization_id' | 'product'>) =>
90
+ automationsApi.create({
91
+ ...automation,
92
+ organization_id: organization.requireOrganizationId(),
93
+ product: product.value!,
94
+ }),
95
+ onSuccess: () => {
96
+ queryCache.invalidateQueries({ key: ['automations'] })
97
+ },
98
+ })
99
+ }
100
+
101
+ /** An update names its Product; the Organization is the stored one (ADR 0016). */
102
+ export function useUpdateAutomationMutation() {
103
+ const queryCache = useQueryCache()
104
+ const { product } = storeToRefs(useAppContextStore())
105
+
106
+ return useMutation({
107
+ mutation: ({ id, ...changes }: AutomationChanges & { id: string }) =>
108
+ automationsApi.update(id, { ...changes, product: product.value! }),
109
+ // After a failure too: an update can be saved in part.
110
+ onSettled: () => {
111
+ queryCache.invalidateQueries({ key: ['automations'] })
112
+ },
113
+ })
114
+ }
115
+
116
+ /** A clone names its Product; the copy stays in the source's Organization (ADR 0016). */
117
+ export function useCloneAutomationMutation() {
118
+ const queryCache = useQueryCache()
119
+ const { product } = storeToRefs(useAppContextStore())
120
+
121
+ return useMutation({
122
+ mutation: ({ id, name }: { id: string; name: string }) =>
123
+ automationsApi.clone(id, { name, product: product.value! }),
124
+ // The copy comes with Message Groups of its own. After a failure too: it
125
+ // can be made when the answer is lost.
126
+ onSettled: () => {
127
+ queryCache.invalidateQueries({ key: ['automations'] })
128
+ queryCache.invalidateQueries({ key: ['message-groups'] })
129
+ },
130
+ })
131
+ }
132
+
133
+ /**
134
+ * The Message Groups the Actions of an Automation use, each once, by their
135
+ * current name. One that cannot be read (deleted, or of an Organization the
136
+ * user cannot reach) is left out: it could not be deleted either.
137
+ */
138
+ export function useAutomationMessageGroupsQuery(automation: MaybeRefOrGetter<Automation>) {
139
+ const ids = computed(() => [
140
+ ...new Set(toValue(automation).actions.map((action) => action.config.template.toLowerCase())),
141
+ ])
142
+
143
+ return useQuery({
144
+ key: () => ['automation-message-groups', toValue(automation).id, ...ids.value],
145
+ query: async () => {
146
+ const results = await Promise.allSettled(
147
+ ids.value.map((id) => messageGroupsApi.getById(id, { skipErrorToast: true })),
148
+ )
149
+ return results.flatMap((result) =>
150
+ result.status === 'fulfilled' ? [{ id: result.value.id, name: result.value.name }] : [],
151
+ )
152
+ },
153
+ staleTime: 0,
154
+ gcTime: 0,
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Whether each of these Message Groups is approved in every language, by its
160
+ * id in lower case. One that cannot be read is left out: it is not known to
161
+ * be unapproved. Under `message-groups`, so a change to any of them asks again.
162
+ */
163
+ export function useMessageGroupApprovalsQuery(messageGroupIds: MaybeRefOrGetter<string[]>) {
164
+ const ids = computed(() =>
165
+ [...new Set(toValue(messageGroupIds).map((id) => id.toLowerCase()))].filter(Boolean).sort(),
166
+ )
167
+
168
+ return useQuery({
169
+ key: () => ['message-groups', 'approvals', ...ids.value],
170
+ query: async () => {
171
+ const requested = ids.value
172
+ const results = await Promise.allSettled(
173
+ requested.map((id) => messageGroupsApi.getById(id, { skipErrorToast: true })),
174
+ )
175
+ return new Map(
176
+ results.flatMap((result, index) => {
177
+ if (result.status !== 'fulfilled') return []
178
+ const { messages } = result.value
179
+ const approved =
180
+ messages.length > 0 && messages.every((message) => message.status === 'approved')
181
+ return [[requested[index]!, approved] as const]
182
+ }),
183
+ )
184
+ },
185
+ enabled: () => ids.value.length > 0,
186
+ // The set changes with every Action added or re-pointed: what was known stays while it loads.
187
+ placeholderData: (previous) => previous,
188
+ staleTime: 2 * 60 * 1000,
189
+ })
190
+ }
191
+
192
+ /** A delete names its Product, and the Message Groups that go with the Automation (ADR 0016). */
193
+ export function useDeleteAutomationMutation() {
194
+ const queryCache = useQueryCache()
195
+ const { product } = storeToRefs(useAppContextStore())
196
+
197
+ return useMutation({
198
+ mutation: ({ id, messageGroupIds }: { id: string; messageGroupIds: string[] }) =>
199
+ automationsApi.delete(id, { product: product.value!, message_group_ids: messageGroupIds }),
200
+ // After a failure too: the Automation can be gone when the answer is lost.
201
+ onSettled: (_result, _error, { messageGroupIds }) => {
202
+ queryCache.invalidateQueries({ key: ['automations'] })
203
+ if (messageGroupIds.length > 0) queryCache.invalidateQueries({ key: ['message-groups'] })
204
+ },
205
+ })
206
+ }
@@ -7,3 +7,4 @@ export * from './meta'
7
7
  export * from './compilation'
8
8
  export * from './dynamicValues'
9
9
  export * from './brands'
10
+ export * from './automations'
@@ -56,9 +56,7 @@ export function useMessageGroupListQuery(
56
56
  // Never across Organizations: a switch must not show the previous one's rows.
57
57
  placeholderData: (previousData, previousEntry) =>
58
58
  previousEntry?.key.at(-1) === listOrganizationId() ? previousData : undefined,
59
- // Until an Organization is resolved the list waits (pending, so callers
60
- // show it as loading) instead of failing into an empty table.
61
- enabled: () => toValue(queryOptions.enabled ?? true) && !!listOrganizationId(),
59
+ ...enabledOption(queryOptions.enabled),
62
60
  })
63
61
  }
64
62
 
@@ -25,8 +25,6 @@ export const useAppContextStore = defineStore('appContext', () => {
25
25
  const isAdmin = computed(() => ctx.isAdmin?.value ?? false)
26
26
  // The Host's preselection among the Organization's WABAs (ADR 0015).
27
27
  const metaWabaId = computed(() => ctx.metaWabaId?.value || undefined)
28
- // The Host's WABA is the only one it works on: no choice is offered.
29
- const metaWabaLocked = computed(() => ctx.metaWabaLocked?.value ?? false)
30
28
 
31
29
  // Sync i18n locale when mainLanguage changes
32
30
  watch(
@@ -60,7 +58,6 @@ export const useAppContextStore = defineStore('appContext', () => {
60
58
  default_tags,
61
59
  isAdmin,
62
60
  metaWabaId,
63
- metaWabaLocked,
64
61
 
65
62
  validationErrors,
66
63
  }
@@ -0,0 +1,67 @@
1
+ /** The Message Group an editor saved. */
2
+ export interface EditorTripResult {
3
+ id: string
4
+ name: string
5
+ }
6
+
7
+ export interface EditorTrip<Kept = unknown> {
8
+ /** Where the trip started: the route it returns to, and the back button's label, already translated. */
9
+ origin: { routeName: string; backLabel: string }
10
+ /** What the opener wants back on its return. The editor never reads it. */
11
+ kept: Kept
12
+ /** The last Message Group saved during the trip; none until a save. */
13
+ result?: EditorTripResult
14
+ }
15
+
16
+ /** The pages a trip goes through, by Channel. */
17
+ export const EDITOR_ROUTES = {
18
+ email: { create: 'email-message-create', edit: 'email-message-edit' },
19
+ whatsapp: { create: 'whatsapp-message-create', edit: 'whatsapp-message-edit' },
20
+ } as const
21
+
22
+ const EDITOR_ROUTE_NAMES: ReadonlySet<string> = new Set(
23
+ Object.values(EDITOR_ROUTES).flatMap((routes) => Object.values(routes)),
24
+ )
25
+
26
+ /**
27
+ * A round trip from a page to the Message editor and back. Layer-internal and
28
+ * in memory only: not Host contract, and a reload loses it.
29
+ *
30
+ * The opener starts it and takes it back on its return. The editor knows a
31
+ * trip, not who opened it: it shows the trip's back button and records what
32
+ * it saves.
33
+ */
34
+ export const useEditorTripStore = defineStore('editorTrip', () => {
35
+ const trip = shallowRef<EditorTrip>()
36
+
37
+ // A trip ends where the user leaves its path: an editor opened later, from
38
+ // the library, is not part of it.
39
+ useRouter().afterEach((to, _from, failure) => {
40
+ if (failure || !trip.value) return
41
+ const name = String(to.name)
42
+ if (name === trip.value.origin.routeName || EDITOR_ROUTE_NAMES.has(name)) return
43
+ trip.value = undefined
44
+ })
45
+
46
+ function start<Kept>(origin: EditorTrip['origin'], kept: Kept) {
47
+ trip.value = { origin, kept }
48
+ }
49
+
50
+ /** The last Message Group saved wins, also if the user then cancels. */
51
+ function recordSaved(saved: EditorTripResult) {
52
+ if (trip.value) trip.value = { ...trip.value, result: saved }
53
+ }
54
+
55
+ /** The trip that started on this route, handed over once. */
56
+ function consume<Kept>(routeName: string): EditorTrip<Kept> | undefined {
57
+ const current = trip.value
58
+ if (current?.origin.routeName !== routeName) return undefined
59
+ trip.value = undefined
60
+ return current as EditorTrip<Kept>
61
+ }
62
+
63
+ /** The label of the editor's back button; none without a trip. */
64
+ const backLabel = computed(() => trip.value?.origin.backLabel)
65
+
66
+ return { start, recordSaved, consume, backLabel }
67
+ })
@@ -2,11 +2,14 @@ declare module 'nuxt/schema' {
2
2
  interface AppConfigInput {
3
3
  composer?: {
4
4
  tracking?: boolean
5
+ /** Turns the Automation pages on (ADR 0019). Off, their routes answer 404. */
6
+ automations?: boolean
5
7
  }
6
8
  }
7
9
  interface AppConfig {
8
10
  composer: {
9
11
  tracking: boolean
12
+ automations: boolean
10
13
  }
11
14
  }
12
15
  }
@@ -76,6 +76,39 @@ export interface TrackingEventMap {
76
76
  // === Global ===
77
77
  'Page View': { page_name: string }
78
78
 
79
+ // === Automation_list ===
80
+ 'automation_list.delete': { automation_name: string; automation_id: string }
81
+ 'automation_list.duplicate': { automation_name: string; automation_id: string }
82
+ 'automation_list.edit': { automation_name: string; automation_id: string }
83
+ 'automation_list.filter_changed': {
84
+ search_term: string
85
+ /** A Trigger type, a status, a Channel; `all` when not narrowed. */
86
+ trigger_type: string
87
+ status: string
88
+ channel: string
89
+ }
90
+ 'automation_list.sort_changed': { column: string; direction: 'asc' | 'desc' }
91
+
92
+ // === Automation create / edit ===
93
+ 'Automation setup': {
94
+ automation_sender: string
95
+ automation_name: string
96
+ automation_trigger: string
97
+ automation_type: string
98
+ automation_rules_audience: 'all' | 'segmented'
99
+ automation_subject: string
100
+ automation_moment: string
101
+ automation_channel: string
102
+ automation_status: string
103
+ automation_days: number
104
+ created_by: 'user'
105
+ }
106
+ 'automation.clone': { automation_name: string; source_id: string }
107
+ 'automation.trigger_changed': { from: string; to: string }
108
+ 'automation.action_added': { channel: string }
109
+ 'automation.action_deleted': { channel: string }
110
+ 'automation.action_toggled': { channel: string; new_status: string }
111
+
79
112
  // === Brand ===
80
113
  brand_create: BrandBaseProperties & { has_logo: boolean }
81
114
  brand_update: BrandBaseProperties & { changed_fields: string[] }