@dev.smartpricing/message-composer-layer 1.0.12 → 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.
@@ -2,413 +2,12 @@
2
2
  definePageMeta({
3
3
  name: 'email-message-edit',
4
4
  middleware: ['channel-guard'],
5
+ channel: 'email',
5
6
  })
6
7
 
7
- import EmailEditor from '@/components/Email/Editor/Index.vue'
8
- import TranslationEditor from '@/components/Email/TranslationEditor.vue'
9
- import IncompleteTranslationsDialog from '@/components/Common/IncompleteTranslationsDialog.vue'
10
- import LanguageList from '@/components/Common/LanguageList.vue'
11
- import { computed, ref, watch, onUnmounted } from 'vue'
12
- import type {
13
- MessageGroupCategory,
14
- EmailMessage,
15
- PostEntity,
16
- } from '@dev.smartpricing/message-composer-utils/types'
17
- import { syncLayoutFromBase } from '@dev.smartpricing/message-composer-utils/utils'
18
- import { useRoute } from 'vue-router'
19
- import { useMessageGroupQuery, useLanguagesQuery } from '~/queries'
20
- import { useAppContextStore } from '@/stores/appContext'
21
- const { trackEvent } = useTracking()
22
8
  const route = useRoute()
23
-
24
- const { mainLanguage } = storeToRefs(useAppContextStore())
25
-
26
- const language = ref(mainLanguage.value)
27
- const targetLanguage = ref(mainLanguage.value)
28
- const activeTab = ref<'content' | 'translations'>('content')
29
- const { goBackOrFallback } = useGoBack()
30
- const emailEditorStore = useEmailEditorStore()
31
-
32
- const messages = ref<Record<string, EmailMessage>>({})
33
- const { data: messageGroup, isPending: isFetching } = useMessageGroupQuery(
34
- route.params.id as string,
35
- )
36
-
37
- // Store original messageGroup values
38
- const originalMessageGroupName = ref<string>('')
39
- const originalMessageGroupCategory = ref<MessageGroupCategory>('marketing')
40
-
41
- // Initialize change tracking
42
- const messageGroupIdRef = computed(() => (messageGroup.value?.id || route.params.id) as string)
43
- const changeTracking = useChangeTrackingEmail(messageGroupIdRef)
44
-
45
- // Watch for data changes to process messages
46
- watch(
47
- messageGroup,
48
- (newValue) => {
49
- if (!newValue?.messages) return
50
-
51
- // Store original messageGroup metadata
52
- originalMessageGroupName.value = newValue.name
53
- originalMessageGroupCategory.value = newValue.category
54
-
55
- for (const message of newValue.messages) {
56
- if (message.render_type === 'email_v1') {
57
- // Convert MessageFromDb to EmailMessage (remove DB timestamps)
58
- const { created_at, updated_at, ...emailMessage } = message
59
- messages.value[message.language_id] = emailMessage as EmailMessage
60
- }
61
- }
62
-
63
- // Record original states for change tracking
64
- for (const [langId, message] of Object.entries(messages.value)) {
65
- changeTracking.recordOriginalState(langId, message)
66
- }
67
-
68
- const resolved = resolveDefaultLanguage(messages.value, mainLanguage.value)
69
- if (resolved && resolved !== mainLanguage.value) {
70
- language.value = resolved
71
- targetLanguage.value = resolved
72
- }
73
-
74
- // Load current language into store
75
- const currentMessage = messages.value[language.value]
76
- if (currentMessage) {
77
- emailEditorStore.loadFromData(currentMessage)
78
- }
79
-
80
- // Restore brand (null if no brand was saved)
81
- emailEditorStore.initBrand(newValue.brand_id ?? null)
82
-
83
- // Set initial target language for translations tab (first non-base language)
84
- const languages = Object.keys(messages.value)
85
- if (languages.length > 1) {
86
- targetLanguage.value = languages.find((l) => l !== language.value) || language.value
87
- }
88
- },
89
- { immediate: true },
90
- )
91
-
92
- // Watch language changes to load the correct message
93
- watch(language, (newLang) => {
94
- const message = messages.value[newLang]
95
- if (message) {
96
- emailEditorStore.loadFromData(message)
97
- }
98
- })
99
-
100
- // Sync all languages when switching to translations tab
101
- watch(activeTab, (newTab) => {
102
- if (newTab === 'translations') {
103
- const baseMessage = messages.value[language.value]
104
- if (!baseMessage || baseMessage.render_type !== 'email_v1') return
105
-
106
- for (const [langId, message] of Object.entries(messages.value)) {
107
- if (langId !== language.value && message.render_type === 'email_v1') {
108
- message.body.blocks = syncLayoutFromBase(baseMessage.body.blocks, message.body.blocks)
109
- message.body.settings = { ...baseMessage.body.settings }
110
- }
111
- }
112
- }
113
- })
114
-
115
- // Sync store changes back to messages
116
- watch(
117
- () => emailEditorStore.blocks,
118
- () => {
119
- const currentMessage = messages.value[language.value]
120
- if (currentMessage) {
121
- const editorData = emailEditorStore.toJSON()
122
- currentMessage.body.blocks = editorData.blocks
123
- currentMessage.body.settings = editorData.settings
124
- }
125
- },
126
- { deep: true },
127
- )
128
-
129
- // Cleanup on unmount
130
- onUnmounted(() => {
131
- emailEditorStore.resetEditor()
132
- })
133
-
134
- const { formState, draftSchema, validationErrors, validateDraft, validateFull, clearErrors } =
135
- useTemplateFormValidation({
136
- type: 'email',
137
- name: computed(() => messageGroup.value?.name ?? ''),
138
- category: computed(() => messageGroup.value?.category ?? 'marketing'),
139
- messages,
140
- })
141
-
142
- const { saving, updateTemplate } = useTemplateOperations({
143
- onSuccess: () => {
144
- saved.value = true
145
- goBackOrFallback()
146
- },
147
- })
148
-
149
- const saved = ref(false)
150
-
151
- const hasAnyChanges = computed(() => {
152
- // Check if message group name or category changed
153
- if (messageGroup.value) {
154
- const nameChanged = messageGroup.value.name !== originalMessageGroupName.value
155
- const categoryChanged = messageGroup.value.category !== originalMessageGroupCategory.value
156
- if (nameChanged || categoryChanged) return true
157
- }
158
-
159
- // For now, always return true if messages exist (simplified change tracking)
160
- // TODO: Implement proper change tracking for email messages
161
- return Object.keys(messages.value).length > 0
162
- })
163
-
164
- useExitConfirmation(computed(() => hasAnyChanges.value && !saved.value))
165
-
166
- // Get languages for incomplete translations check
167
- const { data: languages } = useLanguagesQuery()
168
-
169
- // Check for incomplete translations
170
- const { incompleteLanguages, hasIncompleteTranslations } = useIncompleteTranslationsEmail({
171
- messages,
172
- languages,
173
- })
174
-
175
- // Dialog state and pending action
176
- const showIncompleteDialog = ref(false)
177
- const pendingAction = ref<'save_draft' | 'save_message' | null>(null)
178
-
179
- // Auto-translate composable
180
- const { translateMessage, isTranslating } = useAutoTranslateEmail()
181
-
182
- // ── Tracking helpers ──
183
- function getEmailBaseProps() {
184
- return {
185
- name: messageGroup.value?.name ?? '',
186
- subject: messages.value[language.value]?.body?.subject ?? '',
187
- }
188
- }
189
-
190
- function getEmailDraftProps() {
191
- return {
192
- ...getEmailBaseProps(),
193
- languages: Object.keys(messages.value),
194
- widgets: emailEditorStore.blocks.map((b) => b.type),
195
- is_internal: messageGroup.value?.visibility === 'internal',
196
- }
197
- }
198
-
199
- watch(activeTab, (tab) => {
200
- trackEvent('email_edit.change_tab', { ...getEmailBaseProps(), tab })
201
- })
202
-
203
- function onAddLanguage(lang: string) {
204
- trackEvent('email_edit.add_language', { ...getEmailBaseProps(), language: lang })
205
- }
206
-
207
- function onRemoveLanguage(lang: string) {
208
- trackEvent('email_edit.remove_language', {
209
- ...getEmailBaseProps(),
210
- language: lang,
211
- })
212
- }
213
-
214
- function onAutoTranslateTracked(lang: string) {
215
- trackEvent('email_edit.auto_translate', {
216
- ...getEmailBaseProps(),
217
- language: lang,
218
- })
219
- }
220
-
221
- function onPreview(lang: string) {
222
- trackEvent('email_edit.preview', { ...getEmailBaseProps(), language: lang })
223
- }
224
-
225
- function onFiltersChanged(payload: {
226
- language: string
227
- search_term: string
228
- show_only_missing: boolean
229
- show_only_changes: boolean
230
- }) {
231
- trackEvent('email_edit.translations_filters', { ...getEmailBaseProps(), ...payload })
232
- }
233
-
234
- function onBlockAdded(payload: { type: string; adding_mode: string }) {
235
- trackEvent('email_edit.add_widget', {
236
- ...getEmailBaseProps(),
237
- widget_type: payload.type,
238
- adding_mode: payload.adding_mode,
239
- })
240
- }
241
-
242
- function onBlockDuplicated(payload: { type: string }) {
243
- trackEvent('email_edit.duplicate_widget', {
244
- ...getEmailBaseProps(),
245
- widget_type: payload.type,
246
- })
247
- }
248
-
249
- function onBlockDeleted(payload: { type: string }) {
250
- trackEvent('email_edit.remove_widget', {
251
- ...getEmailBaseProps(),
252
- widget_type: payload.type,
253
- })
254
- }
255
-
256
- function onBlockSorted(payload: {
257
- type: string
258
- before_sort_position: number
259
- new_position: number
260
- }) {
261
- trackEvent('email_edit.sort_widget', {
262
- ...getEmailBaseProps(),
263
- widget_type: payload.type,
264
- before_sort_position: payload.before_sort_position,
265
- new_position: payload.new_position,
266
- })
267
- }
268
-
269
- function onWidgetTabChanged(tab: string) {
270
- trackEvent('email_edit.change_widget_tab', { ...getEmailBaseProps(), tab })
271
- }
272
-
273
- function onTestSent(payload: { to_recipients: number }, lang?: string) {
274
- trackEvent('email_edit.send_test', {
275
- ...getEmailBaseProps(),
276
- widgets: emailEditorStore.blocks.map((b) => b.type),
277
- to_recipients: payload.to_recipients,
278
- language: lang ?? language.value,
279
- })
280
- }
281
-
282
- // Sync all languages from base before save
283
- function syncAllLanguagesBeforeSave() {
284
- const baseMessage = messages.value[language.value]
285
- if (!baseMessage || baseMessage.render_type !== 'email_v1') return
286
-
287
- for (const [langId, message] of Object.entries(messages.value)) {
288
- if (langId !== language.value && message.render_type === 'email_v1') {
289
- message.body.blocks = syncLayoutFromBase(baseMessage.body.blocks, message.body.blocks)
290
- message.body.settings = { ...baseMessage.body.settings }
291
- }
292
- }
293
- }
294
-
295
- async function saveAsDraft() {
296
- if (!messageGroup.value) return
297
- trackEvent('email_edit.save_draft', getEmailDraftProps())
298
-
299
- syncAllLanguagesBeforeSave()
300
-
301
- const templateData = {
302
- messageGroup: {
303
- name: messageGroup.value.name,
304
- category: messageGroup.value.category,
305
- brand_id: emailEditorStore.brandId,
306
- },
307
- messages: messages.value,
308
- }
309
- await updateTemplate(messageGroup.value.id.toString(), templateData, 'draft')
310
- notifyParent('TEMPLATE_SAVED_DRAFT', { id: messageGroup.value.id, name: messageGroup.value.name })
311
- }
312
-
313
- async function saveTemplate() {
314
- if (!messageGroup.value) return
315
- trackEvent('email_edit.save', getEmailDraftProps())
316
-
317
- syncAllLanguagesBeforeSave()
318
-
319
- const templateData = {
320
- messageGroup: {
321
- name: messageGroup.value.name,
322
- category: messageGroup.value.category,
323
- brand_id: emailEditorStore.brandId,
324
- },
325
- messages: messages.value,
326
- }
327
- await updateTemplate(messageGroup.value.id.toString(), templateData, 'approved')
328
- notifyParent('TEMPLATE_SAVED', {
329
- id: messageGroup.value.id,
330
- name: messageGroup.value.name,
331
- status: 'approved',
332
- })
333
- }
334
-
335
- // Wrapper functions to validate then check for incomplete translations
336
- async function handleSaveAsDraft() {
337
- if (!(await validateDraft())) return
338
- if (hasIncompleteTranslations.value) {
339
- pendingAction.value = 'save_draft'
340
- showIncompleteDialog.value = true
341
- } else {
342
- saveAsDraft()
343
- }
344
- }
345
-
346
- async function handleSaveTemplate() {
347
- if (!(await validateFull())) return
348
- if (hasIncompleteTranslations.value) {
349
- pendingAction.value = 'save_message'
350
- showIncompleteDialog.value = true
351
- } else {
352
- saveTemplate()
353
- }
354
- }
355
-
356
- // Proceed with save without translating
357
- function proceedWithSave() {
358
- if (pendingAction.value === 'save_draft') {
359
- saveAsDraft()
360
- } else if (pendingAction.value === 'save_message') {
361
- saveTemplate()
362
- }
363
- pendingAction.value = null
364
- }
365
-
366
- // Go to translations tab
367
- function goToTranslations() {
368
- activeTab.value = 'translations'
369
- pendingAction.value = null
370
- }
371
-
372
- // Translate automatically and save
373
- async function translateAndSave() {
374
- if (!incompleteLanguages.value.length) {
375
- proceedWithSave()
376
- return
377
- }
378
-
379
- const baseMessage = messages.value[language.value]
380
- if (!baseMessage) {
381
- proceedWithSave()
382
- return
383
- }
384
-
385
- // Translate all incomplete languages
386
- for (const { language: lang } of incompleteLanguages.value) {
387
- const targetMessage = messages.value[lang.id]
388
- if (!targetMessage) continue
389
-
390
- await translateMessage(
391
- baseMessage as EmailMessage,
392
- targetMessage as EmailMessage,
393
- language.value,
394
- lang.id,
395
- )
396
- }
397
-
398
- // After translation, proceed with save
399
- proceedWithSave()
400
- }
401
-
402
- watch(
403
- () => messages.value[mainLanguage.value],
404
- () => {
405
- const resolved = resolveDefaultLanguage(messages.value, mainLanguage.value)
406
- if (resolved && resolved !== mainLanguage.value) {
407
- language.value = resolved
408
- targetLanguage.value = resolved
409
- }
410
- },
411
- )
9
+ const composerRef = ref<InstanceType<typeof ComposerEmailComposer>>()
10
+ const store = useEmailComposerStore()
412
11
  </script>
413
12
 
414
13
  <template>
@@ -424,169 +23,42 @@ watch(
424
23
  value: 'translations',
425
24
  },
426
25
  ]"
427
- :active-tab="activeTab"
26
+ :active-tab="store.activeTab"
428
27
  :test-id="templateEmailEditTestIds.navbar"
429
- @tab-change="activeTab = $event"
28
+ @tab-change="store.activeTab = $event as 'content' | 'translations'"
430
29
  >
431
30
  <template #actions>
432
31
  <UButton
433
- @click="
434
- () => {
435
- notifyParent('TEMPLATE_CANCELLED', { id: messageGroup?.id })
436
- goBackOrFallback()
437
- }
438
- "
32
+ @click="composerRef?.handleCancel()"
439
33
  :label="$t('common.actions.cancel')"
440
34
  color="primary"
441
35
  variant="ghost"
442
- :loading="saving || isTranslating"
36
+ :loading="composerRef?.saving || composerRef?.isTranslating"
443
37
  :data-testid="templateEmailEditTestIds.cancelButton"
444
38
  />
445
39
  <UButton
446
- @click="handleSaveAsDraft"
40
+ @click="composerRef?.handleSaveAsDraft()"
447
41
  :label="$t('pages.email.edit_message_group.save_as_draft')"
448
42
  color="primary"
449
43
  variant="outline"
450
- :loading="saving || isTranslating"
44
+ :loading="composerRef?.saving || composerRef?.isTranslating"
451
45
  :data-testid="templateEmailEditTestIds.saveDraftButton"
452
46
  />
453
47
  <UButton
454
- @click="handleSaveTemplate"
48
+ @click="composerRef?.handleSaveTemplate()"
455
49
  :label="$t('pages.email.edit_message_group.save_message')"
456
50
  color="primary"
457
- :loading="saving || isTranslating"
51
+ :loading="composerRef?.saving || composerRef?.isTranslating"
458
52
  :data-testid="templateEmailEditTestIds.saveTemplateButton"
459
53
  />
460
54
  </template>
461
55
 
462
- <div v-if="validationErrors.length">
463
- <UAlert
464
- color="error"
465
- variant="subtle"
466
- icon="i-lucide-alert-triangle"
467
- :title="$t('editor.validation.error.title')"
468
- :close="true"
469
- @update:open="clearErrors()"
470
- :data-testid="templateEmailEditTestIds.validationAlert"
471
- >
472
- <template #description>
473
- <ul class="list-disc pl-4 space-y-1">
474
- <li v-for="err in validationErrors" :key="err">
475
- {{ err }}
476
- </li>
477
- </ul>
478
- </template>
479
- </UAlert>
480
- </div>
481
-
482
- <UForm
483
- ref="templateForm"
484
- :schema="draftSchema"
485
- :state="formState"
486
- :validate-on="[]"
487
- class="flex-1 relative min-h-0 flex flex-col"
56
+ <ComposerEmailComposer
57
+ ref="composerRef"
58
+ mode="edit"
59
+ :message-group-id="route.params.id as string"
488
60
  >
489
- <UCard
490
- v-if="activeTab === 'content' && messageGroup && messages[language]"
491
- :ui="{
492
- root: 'flex flex-col flex-1 min-h-0',
493
- body: 'flex-1 overflow-hidden sm:px-0 px-0 py-0 sm:py-0 min-h-0',
494
- header: 'sm:px-4 px-4 py-4 sm:py-4',
495
- }"
496
- :data-testid="templateEmailEditTestIds.contentCard"
497
- >
498
- <template #header>
499
- <div class="grid grid-cols-3 gap-2">
500
- <UFieldGroup>
501
- <UBadge variant="outline" color="neutral" size="lg">{{
502
- $t('pages.email.edit_message_group.name')
503
- }}</UBadge>
504
- <UInput
505
- v-model="messageGroup.name"
506
- :placeholder="$t('pages.email.edit_message_group.name_placeholder')"
507
- :data-testid="templateEmailEditTestIds.nameInput"
508
- />
509
- </UFieldGroup>
510
-
511
- <UFieldGroup v-if="messages[language]">
512
- <UBadge variant="outline" color="neutral" size="lg">{{
513
- $t('pages.email.edit_message_group.subject')
514
- }}</UBadge>
515
- <UInput
516
- v-model="messages[language]!.body.subject"
517
- :placeholder="$t('pages.email.edit_message_group.subject_placeholder')"
518
- :data-testid="templateEmailEditTestIds.subjectInput"
519
- />
520
- </UFieldGroup>
521
- </div>
522
- </template>
523
-
524
- <template v-if="messages[language]" :key="language">
525
- <div class="flex flex-col h-full overflow-hidden">
526
- <div class="flex-1 overflow-hidden">
527
- <EmailEditor
528
- v-if="messages[language]"
529
- :key="language"
530
- :message="messages[language] as PostEntity<EmailMessage>"
531
- @block-added="onBlockAdded"
532
- @block-deleted="onBlockDeleted"
533
- @block-duplicated="onBlockDuplicated"
534
- @block-sorted="onBlockSorted"
535
- @widget-tab-changed="onWidgetTabChanged"
536
- @test-sent="onTestSent"
537
- />
538
- </div>
539
- </div>
540
- </template>
541
- </UCard>
542
-
543
- <UCard
544
- v-if="activeTab === 'translations' && messageGroup && messages[language]"
545
- :ui="{
546
- root: 'flex flex-col h-full',
547
- body: 'flex-1 overflow-hidden sm:px-0 px-0 py-0 sm:py-0',
548
- header: 'sm:px-4 px-4 py-4 sm:py-4',
549
- }"
550
- :data-testid="templateEmailEditTestIds.translationsCard"
551
- >
552
- <div
553
- class="grid grid-cols-[auto_1fr] divide-x divide-muted overflow-auto max-h-full h-full"
554
- >
555
- <div class="overflow-y-auto">
556
- <LanguageList
557
- v-model:messages="messages"
558
- v-model:language="targetLanguage"
559
- render-type="email_v1"
560
- :main-language-id="language"
561
- :message-group-id="messageGroup.id"
562
- @add-language="onAddLanguage"
563
- @remove-language="onRemoveLanguage"
564
- />
565
- </div>
566
- <div class="overflow-y-auto" v-if="messages[language] && messages[targetLanguage]">
567
- <TranslationEditor
568
- :base-message="messages[language]!"
569
- v-model="messages[targetLanguage]!"
570
- :messages="messages"
571
- :current-language="targetLanguage"
572
- :base-language="language"
573
- :message-group-id="messageGroup.id"
574
- :key="targetLanguage"
575
- @auto-translate-tracked="onAutoTranslateTracked"
576
- @preview="onPreview"
577
- @filters-changed="onFiltersChanged"
578
- />
579
- </div>
580
- </div>
581
- </UCard>
582
- </UForm>
583
-
584
- <IncompleteTranslationsDialog
585
- v-model="showIncompleteDialog"
586
- :incomplete-languages="incompleteLanguages"
587
- @save-without-translating="proceedWithSave"
588
- @translate-and-save="translateAndSave"
589
- @go-to-translations="goToTranslations"
590
- />
61
+ <template #actions></template>
62
+ </ComposerEmailComposer>
591
63
  </LayoutBasePage>
592
64
  </template>