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

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,190 @@
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
+ </div>
146
+
147
+ <UCard
148
+ :ui="{
149
+ root: 'flex-1 min-h-0 flex flex-col',
150
+ body: 'overflow-auto relative flex-1 p-0 sm:p-0 relative',
151
+ }"
152
+ >
153
+ <UTable
154
+ v-model:column-pinning="columnPinning"
155
+ v-model:sorting="sorting"
156
+ :data="filteredBrands"
157
+ :columns="columns"
158
+ :loading="isPending"
159
+ :empty="t('pages.brands.no_brands')"
160
+ sticky
161
+ class="flex-1 bg-white"
162
+ :ui="{
163
+ tr: 'hover:bg-muted',
164
+ root: 'overflow-visible static',
165
+ }"
166
+ >
167
+ <template #logo-cell="{ row }">
168
+ <img
169
+ v-if="row.original.logo_url"
170
+ :src="row.original.logo_url"
171
+ :alt="row.original.name"
172
+ class="h-8 w-8 rounded-full object-cover border border-neutral-200"
173
+ />
174
+ <div
175
+ v-else
176
+ class="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center text-xs font-medium text-neutral-500"
177
+ >
178
+ {{ row.original.name.charAt(0).toUpperCase() }}
179
+ </div>
180
+ </template>
181
+
182
+ <template #actions-cell="{ row }">
183
+ <SMoreActions :actions="getRowActions(row.original)" />
184
+ </template>
185
+ </UTable>
186
+ </UCard>
187
+ </div>
188
+
189
+ <BrandDrawer v-model="drawerOpen" :brand-id="editingBrandId" @saved="handleDrawerSaved" />
190
+ </template>
@@ -0,0 +1,501 @@
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
+ const trackPrefix = computed(() => (props.mode === 'create' ? 'email_create' : 'email_edit'))
207
+
208
+ function getEmailBaseProps() {
209
+ return {
210
+ name: store.name,
211
+ subject: (store.messages[store.language] as PostEntity<EmailMessage>)?.body?.subject ?? '',
212
+ }
213
+ }
214
+
215
+ function getEmailDraftProps() {
216
+ return {
217
+ ...getEmailBaseProps(),
218
+ languages: Object.keys(store.messages),
219
+ widgets: emailEditorStore.blocks.map((b) => b.type),
220
+ is_internal: store.isInternal,
221
+ }
222
+ }
223
+
224
+ watch(
225
+ () => store.activeTab,
226
+ (tab) => {
227
+ trackEvent(`${trackPrefix.value}.change_tab`, { ...getEmailBaseProps(), tab })
228
+ },
229
+ )
230
+
231
+ // ── Save logic ────────────────────────────────────────────────────
232
+ function syncAllLanguagesBeforeSave() {
233
+ store.syncLayoutToAllLanguages()
234
+ }
235
+
236
+ async function saveAsDraft() {
237
+ trackEvent(`${trackPrefix.value}.save_draft`, getEmailDraftProps())
238
+ syncAllLanguagesBeforeSave()
239
+
240
+ if (props.mode === 'create') {
241
+ const result = await createTemplate(
242
+ { messageGroup: store.messageGroupData, messages: store.messages },
243
+ 'draft',
244
+ )
245
+ if (result) {
246
+ emit('saved', { id: result.id, name: result.name })
247
+ notifyParent('TEMPLATE_SAVED_DRAFT', { id: result.id, name: result.name })
248
+ }
249
+ } else {
250
+ if (!store.messageGroupId) return
251
+ await updateTemplate(
252
+ store.messageGroupId,
253
+ { messageGroup: store.messageGroupUpdateData, messages: store.messages },
254
+ 'draft',
255
+ )
256
+ emit('saved', { id: store.messageGroupId, name: store.name })
257
+ notifyParent('TEMPLATE_SAVED_DRAFT', { id: store.messageGroupId, name: store.name })
258
+ }
259
+ }
260
+
261
+ async function saveTemplate() {
262
+ trackEvent(`${trackPrefix.value}.save`, getEmailDraftProps())
263
+ syncAllLanguagesBeforeSave()
264
+
265
+ if (props.mode === 'create') {
266
+ const result = await createTemplate(
267
+ { messageGroup: store.messageGroupData, messages: store.messages },
268
+ 'approved',
269
+ )
270
+ if (result) {
271
+ emit('saved', { id: result.id, name: result.name, status: 'approved' })
272
+ notifyParent('TEMPLATE_SAVED', { id: result.id, name: result.name, status: 'approved' })
273
+ }
274
+ } else {
275
+ if (!store.messageGroupId) return
276
+ await updateTemplate(
277
+ store.messageGroupId,
278
+ { messageGroup: store.messageGroupUpdateData, messages: store.messages },
279
+ 'approved',
280
+ )
281
+ emit('saved', { id: store.messageGroupId, name: store.name, status: 'approved' })
282
+ notifyParent('TEMPLATE_SAVED', {
283
+ id: store.messageGroupId,
284
+ name: store.name,
285
+ status: 'approved',
286
+ })
287
+ }
288
+ }
289
+
290
+ // ── Public action handlers ────────────────────────────────────────
291
+ async function handleSaveAsDraft() {
292
+ if (!(await validateDraft())) return
293
+ if (hasIncompleteTranslations.value) {
294
+ pendingAction.value = 'save_draft'
295
+ showIncompleteDialog.value = true
296
+ } else {
297
+ saveAsDraft()
298
+ }
299
+ }
300
+
301
+ async function handleSaveTemplate() {
302
+ if (!(await validateFull())) return
303
+ if (hasIncompleteTranslations.value) {
304
+ pendingAction.value = 'save_message'
305
+ showIncompleteDialog.value = true
306
+ } else {
307
+ saveTemplate()
308
+ }
309
+ }
310
+
311
+ function handleCancel() {
312
+ const data =
313
+ props.mode === 'edit' && store.messageGroupId ? { id: store.messageGroupId } : undefined
314
+ emit('cancelled', data)
315
+ notifyParent('TEMPLATE_CANCELLED', data ? { id: data.id } : undefined)
316
+ goBackOrFallback()
317
+ }
318
+
319
+ function proceedWithSave() {
320
+ if (pendingAction.value === 'save_draft') {
321
+ saveAsDraft()
322
+ } else if (pendingAction.value === 'save_message') {
323
+ saveTemplate()
324
+ }
325
+ pendingAction.value = null
326
+ }
327
+
328
+ function goToTranslations() {
329
+ store.activeTab = 'translations'
330
+ pendingAction.value = null
331
+ }
332
+
333
+ async function translateAndSave() {
334
+ if (!incompleteLanguages.value.length) {
335
+ proceedWithSave()
336
+ return
337
+ }
338
+
339
+ const baseMessage = store.messages[store.language]
340
+ if (!baseMessage) {
341
+ proceedWithSave()
342
+ return
343
+ }
344
+
345
+ for (const { language: lang } of incompleteLanguages.value) {
346
+ const targetMsg = store.messages[lang.id]
347
+ if (!targetMsg) continue
348
+
349
+ await translateMessage(
350
+ baseMessage as EmailMessage,
351
+ targetMsg as EmailMessage,
352
+ store.language,
353
+ lang.id,
354
+ )
355
+ }
356
+
357
+ proceedWithSave()
358
+ }
359
+
360
+ // ── Tracking event forwarders ─────────────────────────────────────
361
+ function onAddLanguage(lang: string) {
362
+ trackEvent(`${trackPrefix.value}.add_language`, { ...getEmailBaseProps(), language: lang })
363
+ }
364
+
365
+ function onRemoveLanguage(lang: string) {
366
+ trackEvent(`${trackPrefix.value}.remove_language`, { ...getEmailBaseProps(), language: lang })
367
+ }
368
+
369
+ function onAutoTranslateTracked(lang: string) {
370
+ trackEvent(`${trackPrefix.value}.auto_translate`, { ...getEmailBaseProps(), language: lang })
371
+ }
372
+
373
+ function onPreview(lang: string) {
374
+ trackEvent(`${trackPrefix.value}.preview`, { ...getEmailBaseProps(), language: lang })
375
+ }
376
+
377
+ function onFiltersChanged(payload: {
378
+ language: string
379
+ search_term: string
380
+ show_only_missing: boolean
381
+ show_only_changes: boolean
382
+ }) {
383
+ trackEvent(`${trackPrefix.value}.translations_filters`, { ...getEmailBaseProps(), ...payload })
384
+ }
385
+
386
+ function onBlockAdded(payload: { type: string; adding_mode: string }) {
387
+ trackEvent(`${trackPrefix.value}.add_widget`, {
388
+ ...getEmailBaseProps(),
389
+ widget_type: payload.type,
390
+ adding_mode: payload.adding_mode,
391
+ })
392
+ }
393
+
394
+ function onBlockDuplicated(payload: { type: string }) {
395
+ trackEvent(`${trackPrefix.value}.duplicate_widget`, {
396
+ ...getEmailBaseProps(),
397
+ widget_type: payload.type,
398
+ })
399
+ }
400
+
401
+ function onBlockDeleted(payload: { type: string }) {
402
+ trackEvent(`${trackPrefix.value}.remove_widget`, {
403
+ ...getEmailBaseProps(),
404
+ widget_type: payload.type,
405
+ })
406
+ }
407
+
408
+ function onBlockSorted(payload: {
409
+ type: string
410
+ before_sort_position: number
411
+ new_position: number
412
+ }) {
413
+ trackEvent(`${trackPrefix.value}.sort_widget`, {
414
+ ...getEmailBaseProps(),
415
+ widget_type: payload.type,
416
+ before_sort_position: payload.before_sort_position,
417
+ new_position: payload.new_position,
418
+ })
419
+ }
420
+
421
+ function onWidgetTabChanged(tab: string) {
422
+ trackEvent(`${trackPrefix.value}.change_widget_tab`, { ...getEmailBaseProps(), tab })
423
+ }
424
+
425
+ function onTestSent(payload: { to_recipients: number }, lang?: string) {
426
+ trackEvent(`${trackPrefix.value}.send_test`, {
427
+ ...getEmailBaseProps(),
428
+ widgets: emailEditorStore.blocks.map((b) => b.type),
429
+ to_recipients: payload.to_recipients,
430
+ language: lang ?? store.language,
431
+ })
432
+ }
433
+
434
+ // ── Expose ────────────────────────────────────────────────────────
435
+ defineExpose({
436
+ handleSaveAsDraft,
437
+ handleSaveTemplate,
438
+ handleCancel,
439
+ saving,
440
+ isTranslating,
441
+ validationErrors,
442
+ })
443
+ </script>
444
+
445
+ <template>
446
+ <UForm
447
+ ref="templateForm"
448
+ :schema="draftSchema"
449
+ :state="formState"
450
+ :validate-on="[]"
451
+ class="flex-1 relative min-h-0 flex flex-col"
452
+ >
453
+ <div v-if="validationErrors.length">
454
+ <UAlert
455
+ color="error"
456
+ variant="subtle"
457
+ icon="i-lucide-alert-triangle"
458
+ :title="$t('editor.validation.error.title')"
459
+ :close="true"
460
+ @update:open="clearErrors()"
461
+ >
462
+ <template #description>
463
+ <ul class="list-disc pl-4 space-y-1">
464
+ <li v-for="err in validationErrors" :key="err">
465
+ {{ err }}
466
+ </li>
467
+ </ul>
468
+ </template>
469
+ </UAlert>
470
+ </div>
471
+
472
+ <ComposerEmailContent
473
+ v-if="store.activeTab === 'content'"
474
+ :mode="mode"
475
+ @block-added="onBlockAdded"
476
+ @block-deleted="onBlockDeleted"
477
+ @block-duplicated="onBlockDuplicated"
478
+ @block-sorted="onBlockSorted"
479
+ @widget-tab-changed="onWidgetTabChanged"
480
+ @test-sent="onTestSent"
481
+ />
482
+
483
+ <ComposerEmailTranslations
484
+ v-if="store.activeTab === 'translations'"
485
+ :mode="mode"
486
+ @add-language="onAddLanguage"
487
+ @remove-language="onRemoveLanguage"
488
+ @auto-translate-tracked="onAutoTranslateTracked"
489
+ @preview="onPreview"
490
+ @filters-changed="onFiltersChanged"
491
+ />
492
+ </UForm>
493
+
494
+ <IncompleteTranslationsDialog
495
+ v-model="showIncompleteDialog"
496
+ :incomplete-languages="incompleteLanguages"
497
+ @save-without-translating="proceedWithSave"
498
+ @translate-and-save="translateAndSave"
499
+ @go-to-translations="goToTranslations"
500
+ />
501
+ </template>