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