@dev.smartpricing/message-composer-layer 1.0.13 → 1.0.15

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.
@@ -0,0 +1,191 @@
1
+ <script setup lang="ts">
2
+ import type { BrandFromDb } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { formatDate } from '@dev.smartpricing/message-composer-utils/utils'
4
+ import type { TableColumn } from '#ui/types'
5
+ import { useBrandsQuery, useDeleteBrandMutation } from '~/queries'
6
+ import BrandDrawer from '~/components/Brand/BrandDrawer.vue'
7
+
8
+ const emit = defineEmits<{
9
+ created: [brand: BrandFromDb]
10
+ updated: [brand: BrandFromDb]
11
+ deleted: [brand: BrandFromDb]
12
+ }>()
13
+
14
+ const { t } = useI18n()
15
+ const { confirm } = useConfirm()
16
+
17
+ const { data: brands, isPending } = useBrandsQuery()
18
+ const { mutateAsync: deleteBrand } = useDeleteBrandMutation()
19
+
20
+ // Drawer state
21
+ const drawerOpen = ref(false)
22
+ const editingBrandId = ref<string | undefined>(undefined)
23
+
24
+ // Search
25
+ const search = ref('')
26
+ const filteredBrands = computed(() => {
27
+ if (!brands.value?.length) return []
28
+ if (!search.value.trim()) return brands.value
29
+ const query = search.value.toLowerCase()
30
+ return brands.value.filter((b) => b.name.toLowerCase().includes(query))
31
+ })
32
+
33
+ // Table config
34
+ const columns: TableColumn<BrandFromDb>[] = [
35
+ {
36
+ id: 'logo',
37
+ header: '',
38
+ enableSorting: false,
39
+ meta: {
40
+ class: {
41
+ th: 'w-12',
42
+ td: 'w-12',
43
+ },
44
+ },
45
+ },
46
+ {
47
+ accessorKey: 'name',
48
+ header: (ctx) => getSortableHeader(ctx, t('pages.brands.column_name')),
49
+ },
50
+ {
51
+ accessorKey: 'created_at',
52
+ header: (ctx) => getSortableHeader(ctx, t('pages.brands.column_created')),
53
+ cell: ({ row }) => formatDate(row.original.created_at),
54
+ },
55
+ {
56
+ id: 'actions',
57
+ enableSorting: false,
58
+ header: '',
59
+ meta: {
60
+ class: {
61
+ th: 'w-[66px]',
62
+ td: 'w-[66px]',
63
+ },
64
+ },
65
+ },
66
+ ]
67
+
68
+ const columnPinning = ref({
69
+ right: ['actions'],
70
+ })
71
+
72
+ const sorting = ref([
73
+ {
74
+ id: 'created_at',
75
+ desc: true,
76
+ },
77
+ ])
78
+
79
+ function getRowActions(brand: BrandFromDb) {
80
+ return [
81
+ {
82
+ label: t('common.actions.edit'),
83
+ icon: 'ph:pencil-simple-line',
84
+ onSelect: () => openEdit(brand),
85
+ },
86
+ {
87
+ label: t('common.actions.delete'),
88
+ icon: 'ph:trash',
89
+ color: 'error' as const,
90
+ onSelect: () => askDelete(brand),
91
+ },
92
+ ]
93
+ }
94
+
95
+ function openCreate() {
96
+ editingBrandId.value = undefined
97
+ drawerOpen.value = true
98
+ }
99
+
100
+ function openEdit(brand: BrandFromDb) {
101
+ editingBrandId.value = brand.id
102
+ drawerOpen.value = true
103
+ }
104
+
105
+ function askDelete(brand: BrandFromDb) {
106
+ confirm({
107
+ title: t('pages.brands.delete_confirm_title'),
108
+ message: t('pages.brands.delete_confirm_message', { name: brand.name }),
109
+ destructive: true,
110
+ confirmProps: {
111
+ label: t('common.actions.delete'),
112
+ },
113
+ cancelProps: {
114
+ label: t('common.actions.cancel'),
115
+ },
116
+ action: async () => {
117
+ await deleteBrand(brand.id)
118
+ emit('deleted', brand)
119
+ useToast().add({
120
+ color: 'success',
121
+ title: t('common.success'),
122
+ description: t('pages.brands.brand_deleted', { name: brand.name }),
123
+ })
124
+ },
125
+ })
126
+ }
127
+
128
+ function handleDrawerSaved() {
129
+ drawerOpen.value = false
130
+ }
131
+
132
+ defineExpose({ openCreate, openEdit })
133
+ </script>
134
+
135
+ <template>
136
+ <div class="flex-1 space-y-2.5 relative min-h-0 flex flex-col">
137
+ <div class="flex gap-2">
138
+ <UInput
139
+ v-model="search"
140
+ class="max-w-xs"
141
+ leading-icon="ph:magnifying-glass-bold"
142
+ :placeholder="t('pages.brands.search_placeholder')"
143
+ type="search"
144
+ />
145
+ <slot name="actions"/>
146
+ </div>
147
+
148
+ <UCard
149
+ :ui="{
150
+ root: 'flex-1 min-h-0 flex flex-col',
151
+ body: 'overflow-auto relative flex-1 p-0 sm:p-0 relative',
152
+ }"
153
+ >
154
+ <UTable
155
+ v-model:column-pinning="columnPinning"
156
+ v-model:sorting="sorting"
157
+ :data="filteredBrands"
158
+ :columns="columns"
159
+ :loading="isPending"
160
+ :empty="t('pages.brands.no_brands')"
161
+ sticky
162
+ class="flex-1 bg-white"
163
+ :ui="{
164
+ tr: 'hover:bg-muted',
165
+ root: 'overflow-visible static',
166
+ }"
167
+ >
168
+ <template #logo-cell="{ row }">
169
+ <img
170
+ v-if="row.original.logo_url"
171
+ :src="row.original.logo_url"
172
+ :alt="row.original.name"
173
+ class="h-8 w-8 rounded-full object-cover border border-neutral-200"
174
+ />
175
+ <div
176
+ v-else
177
+ class="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center text-xs font-medium text-neutral-500"
178
+ >
179
+ {{ row.original.name.charAt(0).toUpperCase() }}
180
+ </div>
181
+ </template>
182
+
183
+ <template #actions-cell="{ row }">
184
+ <SMoreActions :actions="getRowActions(row.original)" />
185
+ </template>
186
+ </UTable>
187
+ </UCard>
188
+ </div>
189
+
190
+ <BrandDrawer v-model="drawerOpen" :brand-id="editingBrandId" @saved="handleDrawerSaved" />
191
+ </template>
@@ -0,0 +1,505 @@
1
+ <script setup lang="ts">
2
+ import IncompleteTranslationsDialog from '@/components/Common/IncompleteTranslationsDialog.vue'
3
+ import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
4
+ import type { EmailMessage, PostEntity } from '@dev.smartpricing/message-composer-utils/types'
5
+ import {
6
+ duplicateBlocksForNewLanguage,
7
+ syncLayoutFromBase,
8
+ } from '@dev.smartpricing/message-composer-utils/utils'
9
+ import { useAppContextStore } from '@/stores/appContext'
10
+ import { useMessageGroupQuery, useLanguagesQuery } from '~/queries'
11
+
12
+ const props = defineProps<{
13
+ mode: 'create' | 'edit'
14
+ messageGroupId?: string
15
+ }>()
16
+
17
+ const emit = defineEmits<{
18
+ saved: [data: { id: string | number; name: string; status?: string }]
19
+ cancelled: [data?: { id?: string | number }]
20
+ }>()
21
+
22
+ const store = useEmailComposerStore()
23
+ const emailEditorStore = useEmailEditorStore()
24
+ const { mainLanguage } = storeToRefs(useAppContextStore())
25
+ const { trackEvent } = useTracking()
26
+ const { goBackOrFallback } = useGoBack()
27
+
28
+ // ── Init ──────────────────────────────────────────────────────────
29
+ if (props.mode === 'create') {
30
+ store.initCreate(mainLanguage.value)
31
+ emailEditorStore.initBrand()
32
+ } else {
33
+ // Edit mode: data loaded via watcher below
34
+ }
35
+
36
+ // ── Edit mode: fetch message group ────────────────────────────────
37
+ const { data: messageGroupData, isPending: isFetching } =
38
+ props.mode === 'edit' && props.messageGroupId
39
+ ? useMessageGroupQuery(props.messageGroupId)
40
+ : { data: ref(null), isPending: ref(false) }
41
+
42
+ // Change tracking (edit mode only)
43
+ const messageGroupIdRef = computed(() => store.messageGroupId ?? '')
44
+ const changeTracking = props.mode === 'edit' ? useChangeTrackingEmail(messageGroupIdRef) : null
45
+
46
+ // Watch messageGroupData to init edit mode
47
+ if (props.mode === 'edit') {
48
+ watch(
49
+ messageGroupData,
50
+ (data) => {
51
+ if (!data?.messages) return
52
+
53
+ store.initEdit(data, mainLanguage.value)
54
+
55
+ // Record original states for change tracking
56
+ if (changeTracking) {
57
+ for (const [langId, message] of Object.entries(store.messages)) {
58
+ changeTracking.recordOriginalState(langId, message as EmailMessage)
59
+ }
60
+ }
61
+
62
+ // Load current language into editor store
63
+ const currentMessage = store.currentMessage
64
+ if (currentMessage) {
65
+ emailEditorStore.loadFromData(currentMessage as PostEntity<EmailMessage>)
66
+ }
67
+
68
+ // Restore brand
69
+ emailEditorStore.initBrand(data.brand_id ?? null)
70
+ },
71
+ { immediate: true },
72
+ )
73
+ }
74
+
75
+ // ── Initialize store on mount (create mode) ───────────────────────
76
+ onMounted(() => {
77
+ if (props.mode === 'create') {
78
+ const currentMessage = store.currentMessage
79
+ if (currentMessage) {
80
+ emailEditorStore.loadFromData(currentMessage as PostEntity<EmailMessage>)
81
+ }
82
+ }
83
+ })
84
+
85
+ // ── Watchers ──────────────────────────────────────────────────────
86
+
87
+ // Watch language changes to load correct message into editor
88
+ watch(
89
+ () => store.language,
90
+ (newLang) => {
91
+ if (props.mode === 'create') {
92
+ store.ensureLanguageMessage(newLang)
93
+ }
94
+
95
+ const message = store.messages[newLang]
96
+ if (message) {
97
+ emailEditorStore.loadFromData(message as PostEntity<EmailMessage>)
98
+ }
99
+ },
100
+ )
101
+
102
+ // Sync all languages when switching to translations tab
103
+ watch(
104
+ () => store.activeTab,
105
+ (newTab) => {
106
+ if (newTab === 'translations') {
107
+ store.syncLayoutToAllLanguages()
108
+ }
109
+ },
110
+ )
111
+
112
+ // Set initial target language when languages are added
113
+ watch(
114
+ () => Object.keys(store.messages).length,
115
+ () => {
116
+ const languages = Object.keys(store.messages)
117
+ if (languages.length > 1 && store.targetLanguage === store.language) {
118
+ store.targetLanguage = languages.find((l) => l !== store.language) || store.language
119
+ }
120
+ },
121
+ )
122
+
123
+ // Sync editor store changes back to messages
124
+ watch(
125
+ () => emailEditorStore.blocks,
126
+ () => {
127
+ const currentMessage = store.messages[store.language]
128
+ if (currentMessage && currentMessage.render_type === 'email_v1') {
129
+ const editorData = emailEditorStore.toJSON()
130
+ currentMessage.body.blocks = editorData.blocks
131
+ currentMessage.body.settings = editorData.settings
132
+ }
133
+ },
134
+ { deep: true },
135
+ )
136
+
137
+ // Edit mode: watch for mainLanguage resolution
138
+ if (props.mode === 'edit') {
139
+ watch(
140
+ () => store.messages[mainLanguage.value],
141
+ () => {
142
+ const resolved = resolveDefaultLanguage(store.messages, mainLanguage.value)
143
+ if (resolved && resolved !== mainLanguage.value) {
144
+ store.language = resolved
145
+ store.targetLanguage = resolved
146
+ }
147
+ },
148
+ )
149
+ }
150
+
151
+ // ── Validation ────────────────────────────────────────────────────
152
+ const messagesRef = computed(() => store.messages)
153
+ const { formState, draftSchema, validationErrors, validateDraft, validateFull, clearErrors } =
154
+ useTemplateFormValidation({
155
+ type: 'email',
156
+ name: computed(() => store.name),
157
+ category: computed(() => store.category),
158
+ messages: messagesRef,
159
+ })
160
+
161
+ // ── Template operations ───────────────────────────────────────────
162
+ const successFlag = ref(false)
163
+
164
+ const { saving, createTemplate, updateTemplate } = useTemplateOperations({
165
+ onSuccess: () => {
166
+ successFlag.value = true
167
+ goBackOrFallback()
168
+ },
169
+ })
170
+
171
+ // ── Change detection ──────────────────────────────────────────────
172
+ const hasAnyChanges = computed(() => {
173
+ if (props.mode === 'create') {
174
+ return store.name.trim() !== '' || emailEditorStore.hasBlocks
175
+ }
176
+
177
+ // Edit mode
178
+ if (store.name !== store.originalName) return true
179
+ if (store.category !== store.originalCategory) return true
180
+ return Object.keys(store.messages).length > 0
181
+ })
182
+
183
+ useExitConfirmation(computed(() => hasAnyChanges.value && !successFlag.value))
184
+
185
+ // ── Incomplete translations ───────────────────────────────────────
186
+ const { data: languages } = useLanguagesQuery()
187
+
188
+ const { incompleteLanguages, hasIncompleteTranslations } = useIncompleteTranslationsEmail({
189
+ messages: messagesRef,
190
+ languages,
191
+ })
192
+
193
+ const showIncompleteDialog = ref(false)
194
+ const pendingAction = ref<'save_draft' | 'save_message' | null>(null)
195
+
196
+ // ── Auto-translate ────────────────────────────────────────────────
197
+ const { translateMessage, isTranslating } = useAutoTranslateEmail()
198
+
199
+ // ── Cleanup ───────────────────────────────────────────────────────
200
+ onUnmounted(() => {
201
+ emailEditorStore.resetEditor()
202
+ store.$reset()
203
+ })
204
+
205
+ // ── Tracking helpers ──────────────────────────────────────────────
206
+ function track(suffix: string, data: Record<string, unknown>) {
207
+ const name = props.mode === 'create' ? `email_create.${suffix}` : `email_edit.${suffix}`
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ trackEvent(name as any, data as any)
210
+ }
211
+
212
+ function getEmailBaseProps() {
213
+ return {
214
+ name: store.name,
215
+ subject: (store.messages[store.language] as PostEntity<EmailMessage>)?.body?.subject ?? '',
216
+ }
217
+ }
218
+
219
+ function getEmailDraftProps() {
220
+ return {
221
+ ...getEmailBaseProps(),
222
+ languages: Object.keys(store.messages),
223
+ widgets: emailEditorStore.blocks.map((b) => b.type),
224
+ is_internal: store.isInternal,
225
+ }
226
+ }
227
+
228
+ watch(
229
+ () => store.activeTab,
230
+ (tab) => {
231
+ track('change_tab', { ...getEmailBaseProps(), tab })
232
+ },
233
+ )
234
+
235
+ // ── Save logic ────────────────────────────────────────────────────
236
+ function syncAllLanguagesBeforeSave() {
237
+ store.syncLayoutToAllLanguages()
238
+ }
239
+
240
+ async function saveAsDraft() {
241
+ track('save_draft', getEmailDraftProps())
242
+ syncAllLanguagesBeforeSave()
243
+
244
+ if (props.mode === 'create') {
245
+ const result = await createTemplate(
246
+ { messageGroup: store.messageGroupData, messages: store.messages },
247
+ 'draft',
248
+ )
249
+ if (result) {
250
+ emit('saved', { id: result.id, name: result.name })
251
+ notifyParent('TEMPLATE_SAVED_DRAFT', { id: result.id, name: result.name })
252
+ }
253
+ } else {
254
+ if (!store.messageGroupId) return
255
+ await updateTemplate(
256
+ store.messageGroupId,
257
+ { messageGroup: store.messageGroupUpdateData, messages: store.messages },
258
+ 'draft',
259
+ )
260
+ emit('saved', { id: store.messageGroupId, name: store.name })
261
+ notifyParent('TEMPLATE_SAVED_DRAFT', { id: store.messageGroupId, name: store.name })
262
+ }
263
+ }
264
+
265
+ async function saveTemplate() {
266
+ track('save', getEmailDraftProps())
267
+ syncAllLanguagesBeforeSave()
268
+
269
+ if (props.mode === 'create') {
270
+ const result = await createTemplate(
271
+ { messageGroup: store.messageGroupData, messages: store.messages },
272
+ 'approved',
273
+ )
274
+ if (result) {
275
+ emit('saved', { id: result.id, name: result.name, status: 'approved' })
276
+ notifyParent('TEMPLATE_SAVED', { id: result.id, name: result.name, status: 'approved' })
277
+ }
278
+ } else {
279
+ if (!store.messageGroupId) return
280
+ await updateTemplate(
281
+ store.messageGroupId,
282
+ { messageGroup: store.messageGroupUpdateData, messages: store.messages },
283
+ 'approved',
284
+ )
285
+ emit('saved', { id: store.messageGroupId, name: store.name, status: 'approved' })
286
+ notifyParent('TEMPLATE_SAVED', {
287
+ id: store.messageGroupId,
288
+ name: store.name,
289
+ status: 'approved',
290
+ })
291
+ }
292
+ }
293
+
294
+ // ── Public action handlers ────────────────────────────────────────
295
+ async function handleSaveAsDraft() {
296
+ if (!(await validateDraft())) return
297
+ if (hasIncompleteTranslations.value) {
298
+ pendingAction.value = 'save_draft'
299
+ showIncompleteDialog.value = true
300
+ } else {
301
+ saveAsDraft()
302
+ }
303
+ }
304
+
305
+ async function handleSaveTemplate() {
306
+ if (!(await validateFull())) return
307
+ if (hasIncompleteTranslations.value) {
308
+ pendingAction.value = 'save_message'
309
+ showIncompleteDialog.value = true
310
+ } else {
311
+ saveTemplate()
312
+ }
313
+ }
314
+
315
+ function handleCancel() {
316
+ const data =
317
+ props.mode === 'edit' && store.messageGroupId ? { id: store.messageGroupId } : undefined
318
+ emit('cancelled', data)
319
+ notifyParent('TEMPLATE_CANCELLED', data ? { id: data.id } : undefined)
320
+ goBackOrFallback()
321
+ }
322
+
323
+ function proceedWithSave() {
324
+ if (pendingAction.value === 'save_draft') {
325
+ saveAsDraft()
326
+ } else if (pendingAction.value === 'save_message') {
327
+ saveTemplate()
328
+ }
329
+ pendingAction.value = null
330
+ }
331
+
332
+ function goToTranslations() {
333
+ store.activeTab = 'translations'
334
+ pendingAction.value = null
335
+ }
336
+
337
+ async function translateAndSave() {
338
+ if (!incompleteLanguages.value.length) {
339
+ proceedWithSave()
340
+ return
341
+ }
342
+
343
+ const baseMessage = store.messages[store.language]
344
+ if (!baseMessage) {
345
+ proceedWithSave()
346
+ return
347
+ }
348
+
349
+ for (const { language: lang } of incompleteLanguages.value) {
350
+ const targetMsg = store.messages[lang.id]
351
+ if (!targetMsg) continue
352
+
353
+ await translateMessage(
354
+ baseMessage as EmailMessage,
355
+ targetMsg as EmailMessage,
356
+ store.language,
357
+ lang.id,
358
+ )
359
+ }
360
+
361
+ proceedWithSave()
362
+ }
363
+
364
+ // ── Tracking event forwarders ─────────────────────────────────────
365
+ function onAddLanguage(lang: string) {
366
+ track('add_language', { ...getEmailBaseProps(), language: lang })
367
+ }
368
+
369
+ function onRemoveLanguage(lang: string) {
370
+ track('remove_language', { ...getEmailBaseProps(), language: lang })
371
+ }
372
+
373
+ function onAutoTranslateTracked(lang: string) {
374
+ track('auto_translate', { ...getEmailBaseProps(), language: lang })
375
+ }
376
+
377
+ function onPreview(lang: string) {
378
+ track('preview', { ...getEmailBaseProps(), language: lang })
379
+ }
380
+
381
+ function onFiltersChanged(payload: {
382
+ language: string
383
+ search_term: string
384
+ show_only_missing: boolean
385
+ show_only_changes: boolean
386
+ }) {
387
+ track('translations_filters', { ...getEmailBaseProps(), ...payload })
388
+ }
389
+
390
+ function onBlockAdded(payload: { type: string; adding_mode: string }) {
391
+ track('add_widget', {
392
+ ...getEmailBaseProps(),
393
+ widget_type: payload.type,
394
+ adding_mode: payload.adding_mode,
395
+ })
396
+ }
397
+
398
+ function onBlockDuplicated(payload: { type: string }) {
399
+ track('duplicate_widget', {
400
+ ...getEmailBaseProps(),
401
+ widget_type: payload.type,
402
+ })
403
+ }
404
+
405
+ function onBlockDeleted(payload: { type: string }) {
406
+ track('remove_widget', {
407
+ ...getEmailBaseProps(),
408
+ widget_type: payload.type,
409
+ })
410
+ }
411
+
412
+ function onBlockSorted(payload: {
413
+ type: string
414
+ before_sort_position: number
415
+ new_position: number
416
+ }) {
417
+ track('sort_widget', {
418
+ ...getEmailBaseProps(),
419
+ widget_type: payload.type,
420
+ before_sort_position: payload.before_sort_position,
421
+ new_position: payload.new_position,
422
+ })
423
+ }
424
+
425
+ function onWidgetTabChanged(tab: string) {
426
+ track('change_widget_tab', { ...getEmailBaseProps(), tab })
427
+ }
428
+
429
+ function onTestSent(payload: { to_recipients: number }, lang?: string) {
430
+ track('send_test', {
431
+ ...getEmailBaseProps(),
432
+ widgets: emailEditorStore.blocks.map((b) => b.type),
433
+ to_recipients: payload.to_recipients,
434
+ language: lang ?? store.language,
435
+ })
436
+ }
437
+
438
+ // ── Expose ────────────────────────────────────────────────────────
439
+ defineExpose({
440
+ handleSaveAsDraft,
441
+ handleSaveTemplate,
442
+ handleCancel,
443
+ saving,
444
+ isTranslating,
445
+ validationErrors,
446
+ })
447
+ </script>
448
+
449
+ <template>
450
+ <UForm
451
+ ref="templateForm"
452
+ :schema="draftSchema"
453
+ :state="formState"
454
+ :validate-on="[]"
455
+ class="flex-1 relative min-h-0 flex flex-col"
456
+ >
457
+ <div v-if="validationErrors.length">
458
+ <UAlert
459
+ color="error"
460
+ variant="subtle"
461
+ icon="i-lucide-alert-triangle"
462
+ :title="$t('editor.validation.error.title')"
463
+ :close="true"
464
+ @update:open="clearErrors()"
465
+ >
466
+ <template #description>
467
+ <ul class="list-disc pl-4 space-y-1">
468
+ <li v-for="err in validationErrors" :key="err">
469
+ {{ err }}
470
+ </li>
471
+ </ul>
472
+ </template>
473
+ </UAlert>
474
+ </div>
475
+
476
+ <ComposerEmailContent
477
+ v-if="store.activeTab === 'content'"
478
+ :mode="mode"
479
+ @block-added="onBlockAdded"
480
+ @block-deleted="onBlockDeleted"
481
+ @block-duplicated="onBlockDuplicated"
482
+ @block-sorted="onBlockSorted"
483
+ @widget-tab-changed="onWidgetTabChanged"
484
+ @test-sent="onTestSent"
485
+ />
486
+
487
+ <ComposerEmailTranslations
488
+ v-if="store.activeTab === 'translations'"
489
+ :mode="mode"
490
+ @add-language="onAddLanguage"
491
+ @remove-language="onRemoveLanguage"
492
+ @auto-translate-tracked="onAutoTranslateTracked"
493
+ @preview="onPreview"
494
+ @filters-changed="onFiltersChanged"
495
+ />
496
+ </UForm>
497
+
498
+ <IncompleteTranslationsDialog
499
+ v-model="showIncompleteDialog"
500
+ :incomplete-languages="incompleteLanguages"
501
+ @save-without-translating="proceedWithSave"
502
+ @translate-and-save="translateAndSave"
503
+ @go-to-translations="goToTranslations"
504
+ />
505
+ </template>