@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,376 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ AutomationChannel,
4
+ AutomationTrigger,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+ import { useMessageGroupApprovalsQuery } from '~/queries'
7
+ import {
8
+ AUTOMATION_RECURRENCES,
9
+ cloneFormData,
10
+ dateReferenceValue,
11
+ localSendingTime,
12
+ parseDateReferenceValue,
13
+ type AutomationFormAction,
14
+ type AutomationRecurrence,
15
+ } from '../../utils/automations/form'
16
+ import type { AutomationUnavailableKind } from '../../utils/automations/unavailable'
17
+
18
+ /**
19
+ * The slideover that adds or edits one Action: its Channel, its Message Group
20
+ * and its Schedule. It works on a copy, handed back on Save only.
21
+ */
22
+ const props = defineProps<{
23
+ /** The Action to start from; the slideover is open while there is one. */
24
+ action: AutomationFormAction | undefined
25
+ /** Whether `action` is one the form does not hold yet. */
26
+ isNew: boolean
27
+ trigger: AutomationTrigger
28
+ /** The Channels an Action can be on here. */
29
+ channels: AutomationChannel[]
30
+ /** The Channels the Organization cannot use. */
31
+ unusableChannels: AutomationChannel[]
32
+ }>()
33
+
34
+ const emit = defineEmits<{
35
+ save: [action: AutomationFormAction]
36
+ close: []
37
+ /** The user leaves for the Message editor, with the Action as edited so far. */
38
+ editMessage: [request: { action: AutomationFormAction; target: 'create' | 'edit' }]
39
+ }>()
40
+
41
+ const { t } = useI18n()
42
+
43
+ const edited = ref<AutomationFormAction>()
44
+
45
+ watch(
46
+ () => props.action,
47
+ (action) => {
48
+ edited.value = action ? cloneFormData(action) : undefined
49
+ },
50
+ { immediate: true },
51
+ )
52
+
53
+ const open = computed({
54
+ get: () => Boolean(props.action),
55
+ set: (isOpen) => {
56
+ if (!isOpen) emit('close')
57
+ },
58
+ })
59
+
60
+ const { data: approvals } = useMessageGroupApprovalsQuery(() =>
61
+ edited.value ? [edited.value.messageGroupId] : [],
62
+ )
63
+
64
+ const unavailable = useAutomationUnavailable()
65
+
66
+ /** What the Action being edited holds that is no longer available. */
67
+ const unavailableItems = computed(() =>
68
+ edited.value
69
+ ? unavailable.actionItems(edited.value, {
70
+ trigger: props.trigger,
71
+ unusableChannels: props.unusableChannels,
72
+ approvals: approvals.value,
73
+ })
74
+ : [],
75
+ )
76
+
77
+ function unavailableOf(...kinds: AutomationUnavailableKind[]) {
78
+ return unavailableItems.value.find((item) => kinds.includes(item.kind))
79
+ }
80
+
81
+ const unusableChannel = computed(() => unavailableOf('channel'))
82
+ const unlistedDateReference = computed(() => unavailableOf('date_reference'))
83
+ const unapproved = computed(() => unavailableOf('message_group_inactive', 'message_group_active'))
84
+
85
+ /** The Action's own Channel stays in the list when it cannot be picked here, as a disabled option. */
86
+ const channelOptions = computed(() => {
87
+ const options: { value: AutomationChannel; label: string; disabled?: boolean }[] =
88
+ props.channels.map((channel) => ({
89
+ value: channel,
90
+ label: t(`automations.channels.${channel}`),
91
+ }))
92
+
93
+ const own = edited.value?.channel
94
+ if (own && !props.channels.includes(own)) {
95
+ const channel = t(`automations.channels.${own}`)
96
+ options.unshift({
97
+ value: own,
98
+ label: props.unusableChannels.includes(own)
99
+ ? t('automations.unavailable.channel_option', { channel })
100
+ : channel,
101
+ disabled: true,
102
+ })
103
+ }
104
+ return options
105
+ })
106
+
107
+ function setChannel(channel: AutomationChannel) {
108
+ if (!edited.value || channel === edited.value.channel) return
109
+ edited.value.channel = channel
110
+ // A Message Group is of one Channel.
111
+ edited.value.messageGroupId = ''
112
+ edited.value.messageGroupName = ''
113
+ }
114
+
115
+ /** A stored Date Reference the Trigger no longer lists shows as it is, and cannot be picked. */
116
+ const dateReferenceOptions = computed(() => [
117
+ ...(edited.value && unlistedDateReference.value
118
+ ? [
119
+ {
120
+ value: dateReferenceValue(edited.value),
121
+ label: edited.value.dateReference,
122
+ disabled: true,
123
+ },
124
+ ]
125
+ : []),
126
+ ...props.trigger.date_references.flatMap((reference) =>
127
+ reference.directions.map(({ direction, label }) => ({
128
+ value: dateReferenceValue({ direction, dateReference: reference.key }),
129
+ label,
130
+ })),
131
+ ),
132
+ ])
133
+
134
+ function setDateReference(value: string) {
135
+ if (edited.value) Object.assign(edited.value, parseDateReferenceValue(value))
136
+ }
137
+
138
+ // Every hour of the day, by its UTC value and with the user's clock as label.
139
+ const sendingTimeOptions = Array.from({ length: 24 }, (_, hour) => {
140
+ const value = `${String(hour).padStart(2, '0')}:00:00Z`
141
+ return { value, label: localSendingTime(value) }
142
+ })
143
+
144
+ const NO_RECURRENCE = 'none'
145
+
146
+ const recurrenceOptions = computed(() => [
147
+ { value: NO_RECURRENCE, label: t('automations.recurrence.none') },
148
+ ...AUTOMATION_RECURRENCES.map((recurrence) => ({
149
+ value: recurrence,
150
+ label: t(`automations.recurrence.${recurrence}`),
151
+ })),
152
+ ])
153
+
154
+ // A recurrence and a strict delay exclude each other.
155
+ function setRecurrence(value: string) {
156
+ if (!edited.value) return
157
+ edited.value.recurrence = value === NO_RECURRENCE ? undefined : (value as AutomationRecurrence)
158
+ if (edited.value.recurrence) edited.value.strictDelay = false
159
+ }
160
+
161
+ function setStrictDelay(strict: boolean) {
162
+ if (!edited.value) return
163
+ edited.value.strictDelay = strict
164
+ if (strict) edited.value.recurrence = undefined
165
+ }
166
+
167
+ const advancedOpen = ref(false)
168
+
169
+ // Immediate: an Action reopened after an editor trip is there from the start.
170
+ watch(
171
+ open,
172
+ (isOpen) => {
173
+ if (!isOpen || !edited.value) return
174
+ const { recurrence, strictDelay, preventDuplicates } = edited.value
175
+ advancedOpen.value = Boolean(recurrence) || strictDelay || !preventDuplicates
176
+ },
177
+ { immediate: true },
178
+ )
179
+
180
+ function editMessage(target: 'create' | 'edit') {
181
+ if (edited.value) emit('editMessage', { action: edited.value, target })
182
+ }
183
+
184
+ function save() {
185
+ if (!edited.value?.messageGroupId) return
186
+ // An emptied number input holds no number.
187
+ const days = Math.max(0, Math.round(Number(edited.value.days) || 0))
188
+ emit('save', { ...edited.value, days })
189
+ }
190
+ </script>
191
+
192
+ <template>
193
+ <USlideover
194
+ v-model:open="open"
195
+ close-icon="ph:x"
196
+ :title="isNew ? t('automations.channel.addTitle') : t('automations.channel.editTitle')"
197
+ :ui="{ content: 'max-w-4xl', body: 'p-0 sm:p-0', footer: 'justify-end' }"
198
+ >
199
+ <template #body>
200
+ <div v-if="edited" class="grid grid-cols-2 h-full divide-muted divide-x">
201
+ <div class="space-y-5 py-6">
202
+ <div v-if="unavailableItems.length > 0" class="px-6">
203
+ <AutomationsUnavailable :items="unavailableItems" in-place />
204
+ </div>
205
+
206
+ <UFormField class="px-6" :label="t('automations.channel.channelType')">
207
+ <template v-if="unusableChannel" #hint>
208
+ <AutomationsUnavailableBadge :item="unusableChannel" />
209
+ </template>
210
+ <USelect
211
+ :model-value="edited.channel"
212
+ :items="channelOptions"
213
+ class="w-full"
214
+ @update:model-value="setChannel"
215
+ />
216
+ </UFormField>
217
+
218
+ <USeparator />
219
+
220
+ <UFormField
221
+ class="px-6"
222
+ :label="t('automations.channel.selectMessage')"
223
+ :description="t('automations.channel.selectMessageHint')"
224
+ >
225
+ <template v-if="unapproved" #hint>
226
+ <AutomationsUnavailableBadge :item="unapproved" />
227
+ </template>
228
+ <!-- Approved only: an Action is Active from the start. -->
229
+ <CommonMessageGroupSelect
230
+ :key="edited.channel"
231
+ v-model="edited.messageGroupId"
232
+ :render-type="edited.channel === 'whatsapp' ? 'whatsapp_v1' : 'email_v1'"
233
+ :placeholder="t('automations.channel.selectMessagePlaceholder')"
234
+ :search-placeholder="t('automations.channel.searchMessagePlaceholder')"
235
+ @select="edited.messageGroupName = $event.name"
236
+ />
237
+
238
+ <!-- Not on a Channel that cannot be used here: its editor would turn the user away. -->
239
+ <p
240
+ v-if="channels.includes(edited.channel)"
241
+ class="paragraph-sm text-muted mt-2 flex items-center gap-1"
242
+ >
243
+ {{ t('automations.channel.cannotFindMessage') }}
244
+ <UButton
245
+ variant="link"
246
+ size="sm"
247
+ color="secondary"
248
+ :label="t('automations.channel.createNew')"
249
+ @click="editMessage('create')"
250
+ />
251
+ <UButton
252
+ v-if="edited.messageGroupId"
253
+ variant="link"
254
+ size="sm"
255
+ color="secondary"
256
+ :label="t('common.actions.edit')"
257
+ @click="editMessage('edit')"
258
+ />
259
+ </p>
260
+ </UFormField>
261
+
262
+ <USeparator />
263
+
264
+ <UFormField
265
+ class="px-6"
266
+ :label="t('automations.channel.when')"
267
+ :description="t('automations.channel.whenHint')"
268
+ >
269
+ <template v-if="unlistedDateReference" #hint>
270
+ <AutomationsUnavailableBadge :item="unlistedDateReference" />
271
+ </template>
272
+ <div class="flex items-center gap-2">
273
+ <UFieldGroup>
274
+ <UInputNumber
275
+ v-model="edited.days"
276
+ :min="0"
277
+ :decrement="false"
278
+ :increment="false"
279
+ />
280
+ <UBadge color="neutral" variant="outline" size="sm">
281
+ {{ t('automations.channel.chooseDays') }}
282
+ </UBadge>
283
+ </UFieldGroup>
284
+
285
+ <USelect
286
+ :model-value="dateReferenceValue(edited)"
287
+ :items="dateReferenceOptions"
288
+ class="w-full"
289
+ @update:model-value="setDateReference"
290
+ />
291
+ </div>
292
+ </UFormField>
293
+
294
+ <UFormField class="w-1/2 px-6" :label="t('automations.channel.sendingTime')">
295
+ <USelect v-model="edited.sendingTimeUTC" :items="sendingTimeOptions" class="w-full" />
296
+ </UFormField>
297
+
298
+ <USeparator />
299
+
300
+ <UCollapsible v-model:open="advancedOpen" class="px-6">
301
+ <UButton
302
+ :label="t('automations.advanced')"
303
+ color="neutral"
304
+ variant="ghost"
305
+ size="sm"
306
+ :trailing-icon="advancedOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'"
307
+ block
308
+ />
309
+
310
+ <template #content>
311
+ <div class="space-y-4 pt-4">
312
+ <UFormField :label="t('automations.recurrence.label')">
313
+ <USelect
314
+ :model-value="edited.recurrence ?? NO_RECURRENCE"
315
+ :items="recurrenceOptions"
316
+ class="w-full"
317
+ :disabled="edited.strictDelay"
318
+ @update:model-value="setRecurrence"
319
+ />
320
+ </UFormField>
321
+
322
+ <USwitch
323
+ :model-value="edited.strictDelay"
324
+ :label="t('automations.strictDelay.label')"
325
+ :description="t('automations.strictDelay.hint')"
326
+ @update:model-value="setStrictDelay"
327
+ />
328
+
329
+ <USwitch
330
+ v-model="edited.preventDuplicates"
331
+ :label="t('automations.preventDuplicates.label')"
332
+ :description="t('automations.preventDuplicates.hint')"
333
+ />
334
+ </div>
335
+ </template>
336
+ </UCollapsible>
337
+ </div>
338
+
339
+ <div class="flex flex-col h-full p-6">
340
+ <p class="label-lg mb-3">{{ t('automations.channel.preview') }}</p>
341
+
342
+ <div class="flex-1">
343
+ <CommonPreviewById
344
+ v-if="edited.messageGroupId"
345
+ :id="edited.messageGroupId"
346
+ class="w-full h-full min-h-[400px]"
347
+ show-subject
348
+ />
349
+ <UEmpty
350
+ v-else
351
+ variant="naked"
352
+ icon="ph:image-square"
353
+ :title="t('automations.channel.no_message.title')"
354
+ :description="t('automations.channel.no_message.description')"
355
+ />
356
+ </div>
357
+ </div>
358
+ </div>
359
+ </template>
360
+
361
+ <template #footer>
362
+ <UButton
363
+ color="neutral"
364
+ variant="outline"
365
+ :label="t('common.actions.cancel')"
366
+ @click="open = false"
367
+ />
368
+ <UButton
369
+ color="primary"
370
+ :label="t('common.actions.save')"
371
+ :disabled="!edited?.messageGroupId"
372
+ @click="save"
373
+ />
374
+ </template>
375
+ </USlideover>
376
+ </template>
@@ -0,0 +1,141 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ AutomationChannel,
4
+ AutomationTrigger,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+ import type {
7
+ AutomationMessageRequest,
8
+ OpenAutomationAction,
9
+ } from '~/composables/useAutomationEditorTrip'
10
+ import { automationActionElementId } from '~/composables/useAutomationUnavailable'
11
+ import { newAutomationFormAction, type AutomationFormAction } from '../../utils/automations/form'
12
+ import type { AutomationUnavailableItem } from '../../utils/automations/unavailable'
13
+
14
+ /** The Actions of an Automation: their cards, "add", and the editor of one. */
15
+ const props = defineProps<{
16
+ trigger: AutomationTrigger
17
+ /** The Channels an Action can be on here. */
18
+ channels: AutomationChannel[]
19
+ /** The Channels the Organization cannot use: an Action on one is kept, and not sent. */
20
+ unusableChannels: AutomationChannel[]
21
+ /** What each Action holds that is no longer available, by the Action's key. */
22
+ unavailable: Map<string, AutomationUnavailableItem[]>
23
+ /** The keys of the Actions a refused save pointed at. */
24
+ invalidKeys?: string[]
25
+ /** The form was submitted: having no Action shows as an error. */
26
+ submitted?: boolean
27
+ /** The Action the editor starts open on: the one an editor trip left from. */
28
+ openAction?: OpenAutomationAction
29
+ /** No Action is added, edited or removed: only the Active switch works. */
30
+ readOnly?: boolean
31
+ }>()
32
+
33
+ const emit = defineEmits<{
34
+ /** The user leaves for the Message editor from the Action in the editor. */
35
+ editMessage: [request: AutomationMessageRequest]
36
+ }>()
37
+
38
+ const actions = defineModel<AutomationFormAction[]>({ required: true })
39
+
40
+ const { t } = useI18n()
41
+ const { trackEvent } = useTracking()
42
+ const { confirm } = useConfirm()
43
+
44
+ /** The Action in the editor: a copy of one of the form's, or one not added yet. */
45
+ const editing = ref<AutomationFormAction | undefined>(props.openAction?.action)
46
+ const editingIsNew = ref(props.openAction?.isNew ?? false)
47
+
48
+ function add() {
49
+ const [channel] = props.channels
50
+ if (!channel) return
51
+ editingIsNew.value = true
52
+ editing.value = newAutomationFormAction(props.trigger, channel)
53
+ }
54
+
55
+ function saveEdited(edited: AutomationFormAction) {
56
+ const index = actions.value.findIndex((action) => action.key === edited.key)
57
+ if (index === -1) {
58
+ actions.value = [...actions.value, edited]
59
+ trackEvent('automation.action_added', { channel: edited.channel })
60
+ } else {
61
+ actions.value = actions.value.with(index, edited)
62
+ }
63
+ editing.value = undefined
64
+ }
65
+
66
+ function toggle(action: AutomationFormAction, active: boolean) {
67
+ const status = active ? 'active' : 'inactive'
68
+ trackEvent('automation.action_toggled', { channel: action.channel, new_status: status })
69
+ actions.value = actions.value.map((current) =>
70
+ current.key === action.key ? { ...current, status } : current,
71
+ )
72
+ }
73
+
74
+ function edit(action: AutomationFormAction) {
75
+ editingIsNew.value = false
76
+ editing.value = action
77
+ }
78
+
79
+ async function remove(action: AutomationFormAction) {
80
+ const confirmed = await confirm({
81
+ title: t('automations.form.actions.deleteConfirm'),
82
+ message: t('automations.form.actions.deleteConfirmDescription'),
83
+ confirmProps: { label: t('common.actions.delete') },
84
+ cancelProps: { label: t('common.actions.cancel') },
85
+ destructive: true,
86
+ })
87
+ if (!confirmed) return
88
+
89
+ trackEvent('automation.action_deleted', { channel: action.channel })
90
+ actions.value = actions.value.filter((current) => current.key !== action.key)
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <UFormField
96
+ :label="t('automations.form.messages')"
97
+ :error="submitted && actions.length === 0 ? t('automations.form.required') : undefined"
98
+ >
99
+ <div class="space-y-4">
100
+ <p v-if="actions.length === 0" class="paragraph-sm text-muted">
101
+ {{ t('automations.form.actions.empty') }}
102
+ </p>
103
+
104
+ <div v-else class="space-y-3">
105
+ <AutomationsActionCard
106
+ v-for="action in actions"
107
+ :id="automationActionElementId(action.key)"
108
+ :key="action.key"
109
+ :action="action"
110
+ :trigger="trigger"
111
+ :unavailable="unavailable.get(action.key) ?? []"
112
+ :invalid="invalidKeys?.includes(action.key)"
113
+ :read-only="readOnly"
114
+ @edit="edit(action)"
115
+ @remove="remove(action)"
116
+ @toggle="toggle(action, $event)"
117
+ />
118
+ </div>
119
+
120
+ <UButton
121
+ v-if="!readOnly"
122
+ color="primary"
123
+ variant="outline"
124
+ :label="t('automations.actions.addChannel')"
125
+ :disabled="channels.length === 0"
126
+ @click="add"
127
+ />
128
+ </div>
129
+
130
+ <AutomationsActionEditor
131
+ :action="editing"
132
+ :is-new="editingIsNew"
133
+ :trigger="trigger"
134
+ :channels="channels"
135
+ :unusable-channels="unusableChannels"
136
+ @save="saveEdited"
137
+ @close="editing = undefined"
138
+ @edit-message="emit('editMessage', { ...$event, isNew: editingIsNew })"
139
+ />
140
+ </UFormField>
141
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Asks the name of the copy before an Automation is duplicated. Opened
4
+ * through `useOverlay`; `action` makes the copy while the modal shows it
5
+ * running, and the modal closes once it is made.
6
+ */
7
+ const props = defineProps<{
8
+ defaultName: string
9
+ action: (name: string) => Promise<void>
10
+ }>()
11
+
12
+ const emit = defineEmits<{ close: [] }>()
13
+
14
+ const { t } = useI18n()
15
+
16
+ const name = ref(props.defaultName)
17
+ const trimmedName = computed(() => name.value.trim())
18
+ const cloning = ref(false)
19
+
20
+ async function confirmClone() {
21
+ if (!trimmedName.value || cloning.value) return
22
+
23
+ cloning.value = true
24
+ try {
25
+ await props.action(trimmedName.value)
26
+ emit('close')
27
+ } catch {
28
+ // Already said by the request's own error toast; the modal stays open
29
+ // with the name, for another try.
30
+ } finally {
31
+ cloning.value = false
32
+ }
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <UModal
38
+ :title="t('automations.clone.title')"
39
+ close-icon="ph:x"
40
+ :close="{ disabled: cloning, onClick: () => emit('close') }"
41
+ :dismissible="false"
42
+ >
43
+ <template #body>
44
+ <UFormField :label="t('automations.form.name')">
45
+ <UInput
46
+ v-model="name"
47
+ :placeholder="t('automations.clone.namePlaceholder')"
48
+ :disabled="cloning"
49
+ autofocus
50
+ class="w-full"
51
+ @keyup.enter="confirmClone"
52
+ />
53
+ </UFormField>
54
+ </template>
55
+
56
+ <template #footer>
57
+ <div class="flex gap-2 justify-end w-full">
58
+ <UButton
59
+ variant="outline"
60
+ color="neutral"
61
+ :label="t('common.actions.cancel')"
62
+ :disabled="cloning"
63
+ @click="emit('close')"
64
+ />
65
+ <UButton
66
+ :label="t('common.actions.duplicate')"
67
+ :loading="cloning"
68
+ :disabled="!trimmedName"
69
+ @click="confirmClone"
70
+ />
71
+ </div>
72
+ </template>
73
+ </UModal>
74
+ </template>