@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.
- package/app/components/Composer/BrandList.vue +190 -0
- package/app/components/Composer/EmailComposer.vue +501 -0
- package/app/components/Composer/EmailContent.vue +95 -0
- package/app/components/Composer/EmailTranslations.vue +70 -0
- package/app/components/Composer/ImageGallery.vue +141 -0
- package/app/components/Composer/MessageLibrary.vue +155 -0
- package/app/components/Composer/WhatsappComposer.vue +424 -0
- package/app/components/Composer/WhatsappContent.vue +88 -0
- package/app/components/Composer/WhatsappTranslations.vue +68 -0
- package/app/pages/brands.vue +7 -176
- package/app/pages/images.vue +1 -126
- package/app/pages/index.vue +6 -138
- package/app/pages/template/email/[id].vue +16 -545
- package/app/pages/template/email/create.vue +13 -514
- package/app/pages/template/whatsapp/[id].vue +21 -509
- package/app/pages/template/whatsapp/create.vue +13 -426
- package/app/stores/emailComposer.ts +188 -0
- package/app/stores/whatsappComposer.ts +173 -0
- package/package.json +2 -2
|
@@ -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>
|