@dev.smartpricing/message-composer-layer 1.0.1

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.
Files changed (169) hide show
  1. package/README.md +75 -0
  2. package/app/api/ai.ts +28 -0
  3. package/app/api/brands.ts +54 -0
  4. package/app/api/client.ts +56 -0
  5. package/app/api/compilation.ts +34 -0
  6. package/app/api/dynamicValues.ts +11 -0
  7. package/app/api/index.ts +11 -0
  8. package/app/api/languages.ts +6 -0
  9. package/app/api/media.ts +26 -0
  10. package/app/api/messageGroups.ts +49 -0
  11. package/app/api/messages.ts +22 -0
  12. package/app/api/meta.ts +18 -0
  13. package/app/api/tracking.ts +9 -0
  14. package/app/components/Brand/BrandDrawer.vue +281 -0
  15. package/app/components/Brand/BrandPreview.vue +137 -0
  16. package/app/components/Brand/Steps/BrandStepGeneral.vue +37 -0
  17. package/app/components/Brand/Steps/BrandStepSocials.vue +48 -0
  18. package/app/components/Brand/Steps/BrandStepVisual.vue +214 -0
  19. package/app/components/Common/BrandColorSwatches.vue +37 -0
  20. package/app/components/Common/ColorInput.vue +144 -0
  21. package/app/components/Common/EmojiPickerButton.vue +35 -0
  22. package/app/components/Common/FontFamilySelect.vue +39 -0
  23. package/app/components/Common/HtmlPreviewIframe.vue +68 -0
  24. package/app/components/Common/IncompleteTranslationsDialog.vue +97 -0
  25. package/app/components/Common/LanguageList.vue +356 -0
  26. package/app/components/Common/LoadingOverlay.vue +12 -0
  27. package/app/components/Common/MediaUploadOrSelect.vue +299 -0
  28. package/app/components/Common/MessagePreviewSlideover.vue +88 -0
  29. package/app/components/Common/MonacoEditor.vue +110 -0
  30. package/app/components/Common/PhoneInput.vue +804 -0
  31. package/app/components/Common/SectionHeader.vue +26 -0
  32. package/app/components/Common/TranslationActionBar.vue +139 -0
  33. package/app/components/Common/ViewLessMore.vue +40 -0
  34. package/app/components/CreateTemplateButton.vue +59 -0
  35. package/app/components/DynamicDataButton.vue +75 -0
  36. package/app/components/DynamicDataSlideover.vue +149 -0
  37. package/app/components/Editor/AiGenerateSlideover.vue +292 -0
  38. package/app/components/Editor/EditorLinkPopover.vue +154 -0
  39. package/app/components/Editor/RichTextEditor.vue +495 -0
  40. package/app/components/Email/Editor/BlockLibrary.vue +89 -0
  41. package/app/components/Email/Editor/BlockSettings.vue +60 -0
  42. package/app/components/Email/Editor/Blocks/ButtonBlock.vue +117 -0
  43. package/app/components/Email/Editor/Blocks/ButtonSettings.vue +228 -0
  44. package/app/components/Email/Editor/Blocks/DiscountCodeBlock.vue +74 -0
  45. package/app/components/Email/Editor/Blocks/DiscountCodeSettings.vue +162 -0
  46. package/app/components/Email/Editor/Blocks/DividerBlock.vue +31 -0
  47. package/app/components/Email/Editor/Blocks/DividerSettings.vue +108 -0
  48. package/app/components/Email/Editor/Blocks/FooterBlock.vue +61 -0
  49. package/app/components/Email/Editor/Blocks/FooterSettings.vue +123 -0
  50. package/app/components/Email/Editor/Blocks/GridBlock.vue +128 -0
  51. package/app/components/Email/Editor/Blocks/GridSettings.vue +109 -0
  52. package/app/components/Email/Editor/Blocks/HTMLBlock.vue +31 -0
  53. package/app/components/Email/Editor/Blocks/HTMLSettings.vue +85 -0
  54. package/app/components/Email/Editor/Blocks/HeaderBlock.vue +53 -0
  55. package/app/components/Email/Editor/Blocks/HeaderSettings.vue +113 -0
  56. package/app/components/Email/Editor/Blocks/HeadingBlock.vue +61 -0
  57. package/app/components/Email/Editor/Blocks/HeadingSettings.vue +156 -0
  58. package/app/components/Email/Editor/Blocks/ImageBlock.vue +49 -0
  59. package/app/components/Email/Editor/Blocks/ImageSettings.vue +53 -0
  60. package/app/components/Email/Editor/Blocks/IndexBlock.vue +155 -0
  61. package/app/components/Email/Editor/Blocks/ParagraphBlock.vue +61 -0
  62. package/app/components/Email/Editor/Blocks/ParagraphSettings.vue +157 -0
  63. package/app/components/Email/Editor/Blocks/SocialsBlock.vue +63 -0
  64. package/app/components/Email/Editor/Blocks/SocialsSettings.vue +117 -0
  65. package/app/components/Email/Editor/Blocks/SpacerBlock.vue +37 -0
  66. package/app/components/Email/Editor/Blocks/SpacerSettings.vue +95 -0
  67. package/app/components/Email/Editor/Blocks/TopProductsBlock.vue +47 -0
  68. package/app/components/Email/Editor/Blocks/TopProductsSettings.vue +87 -0
  69. package/app/components/Email/Editor/Blocks/UnsubscribeLinkBlock.vue +61 -0
  70. package/app/components/Email/Editor/Blocks/UnsubscribeLinkSettings.vue +123 -0
  71. package/app/components/Email/Editor/Canvas.vue +143 -0
  72. package/app/components/Email/Editor/GeneralStyleSettings.vue +125 -0
  73. package/app/components/Email/Editor/Index.vue +77 -0
  74. package/app/components/Email/Editor/LeftSidebar.vue +95 -0
  75. package/app/components/Email/InlinePreview.vue +124 -0
  76. package/app/components/Email/Preview.vue +126 -0
  77. package/app/components/Email/PreviewPanel.vue +57 -0
  78. package/app/components/Email/SendTestEmailModal.vue +121 -0
  79. package/app/components/Email/TranslationEditor.vue +198 -0
  80. package/app/components/Email/Translator/Button.vue +101 -0
  81. package/app/components/Email/Translator/Discount.vue +60 -0
  82. package/app/components/Email/Translator/Footer.vue +66 -0
  83. package/app/components/Email/Translator/Grid.vue +127 -0
  84. package/app/components/Email/Translator/Header.vue +90 -0
  85. package/app/components/Email/Translator/Heading.vue +66 -0
  86. package/app/components/Email/Translator/Image.vue +91 -0
  87. package/app/components/Email/Translator/Index.vue +138 -0
  88. package/app/components/Email/Translator/Paragraph.vue +66 -0
  89. package/app/components/Email/Translator/RawHtml.vue +64 -0
  90. package/app/components/Email/Translator/UnsubscribeLink.vue +59 -0
  91. package/app/components/TemplateList/BaseTable.vue +354 -0
  92. package/app/components/TemplateList/StatusCell.vue +67 -0
  93. package/app/components/Whatsapp/CategoryChanger.vue +63 -0
  94. package/app/components/Whatsapp/Editor/Body.vue +53 -0
  95. package/app/components/Whatsapp/Editor/Buttons.vue +341 -0
  96. package/app/components/Whatsapp/Editor/Footer.vue +70 -0
  97. package/app/components/Whatsapp/Editor/Header.vue +156 -0
  98. package/app/components/Whatsapp/Editor/Index.vue +89 -0
  99. package/app/components/Whatsapp/Editor/SyncWrapper.vue +166 -0
  100. package/app/components/Whatsapp/Preview.vue +197 -0
  101. package/app/components/Whatsapp/PreviewPanel.vue +24 -0
  102. package/app/components/Whatsapp/TranslationEditor.vue +150 -0
  103. package/app/components/Whatsapp/Translator/Body.vue +61 -0
  104. package/app/components/Whatsapp/Translator/Button.vue +209 -0
  105. package/app/components/Whatsapp/Translator/Footer.vue +56 -0
  106. package/app/components/Whatsapp/Translator/Header.vue +61 -0
  107. package/app/components/Whatsapp/Translator/HeaderMedia.vue +117 -0
  108. package/app/components/Whatsapp/Translator/Index.vue +159 -0
  109. package/app/composables/composerContext.ts +35 -0
  110. package/app/composables/useAutoTranslate.ts +242 -0
  111. package/app/composables/useAutoTranslateEmail.ts +294 -0
  112. package/app/composables/useChangeTracking.ts +212 -0
  113. package/app/composables/useChangeTrackingEmail.ts +227 -0
  114. package/app/composables/useChannels.ts +36 -0
  115. package/app/composables/useEmailBlockContext.ts +43 -0
  116. package/app/composables/useExitConfirmation.ts +39 -0
  117. package/app/composables/useGoBack.ts +15 -0
  118. package/app/composables/useIncompleteTranslations.ts +55 -0
  119. package/app/composables/useIncompleteTranslationsEmail.ts +56 -0
  120. package/app/composables/useMediaLimits.ts +42 -0
  121. package/app/composables/useTemplateFormValidation.ts +109 -0
  122. package/app/composables/useTemplateOperations.ts +147 -0
  123. package/app/composables/useTracking.ts +39 -0
  124. package/app/composables/useTranslationFilters.ts +176 -0
  125. package/app/composables/useTranslationFiltersEmail.ts +244 -0
  126. package/app/composables/useWhatsappTemplateBehaviors.ts +58 -0
  127. package/app/middleware/channel-guard.ts +13 -0
  128. package/app/pages/brands.vue +195 -0
  129. package/app/pages/images.vue +141 -0
  130. package/app/pages/index.vue +198 -0
  131. package/app/pages/preview/[id].vue +47 -0
  132. package/app/pages/template/email/[id].vue +596 -0
  133. package/app/pages/template/email/create.vue +562 -0
  134. package/app/pages/template/whatsapp/[id].vue +561 -0
  135. package/app/pages/template/whatsapp/create.vue +468 -0
  136. package/app/plugins/admin-shortcut.client.ts +7 -0
  137. package/app/plugins/tracking.client.ts +12 -0
  138. package/app/queries/ai.ts +20 -0
  139. package/app/queries/brands.ts +75 -0
  140. package/app/queries/compilation.ts +37 -0
  141. package/app/queries/dynamicValues.ts +13 -0
  142. package/app/queries/index.ts +9 -0
  143. package/app/queries/languages.ts +10 -0
  144. package/app/queries/media.ts +27 -0
  145. package/app/queries/messageGroups.ts +112 -0
  146. package/app/queries/messages.ts +42 -0
  147. package/app/queries/meta.ts +13 -0
  148. package/app/stores/adminStore.ts +50 -0
  149. package/app/stores/appContext.ts +99 -0
  150. package/app/stores/emailEditor.ts +220 -0
  151. package/app/stores/main.ts +3 -0
  152. package/app/stores/metaSubmission.ts +116 -0
  153. package/app/types/app-config.d.ts +14 -0
  154. package/app/types/hooks.d.ts +9 -0
  155. package/app/types/tracking.ts +148 -0
  156. package/app/utils/locale.ts +17 -0
  157. package/app/utils/notifyParent.ts +7 -0
  158. package/app/utils/resolveDefaultLanguage.ts +14 -0
  159. package/app/utils/socialDefaults.ts +42 -0
  160. package/app/utils/testIds.const.ts +728 -0
  161. package/app.config.ts +5 -0
  162. package/i18n/check-translations.ts +56 -0
  163. package/i18n/locales/de.ts +834 -0
  164. package/i18n/locales/en.ts +826 -0
  165. package/i18n/locales/es.ts +833 -0
  166. package/i18n/locales/fr.ts +837 -0
  167. package/i18n/locales/it.ts +831 -0
  168. package/nuxt.config.ts +65 -0
  169. package/package.json +64 -0
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import type { JSONContent } from '@tiptap/vue-3'
3
+ import { generateHTML } from '@tiptap/vue-3'
4
+ import { getTipTapExtensions } from '@dev.smartpricing/message-composer-utils/utils'
5
+ import RichTextEditor from '@/components/Editor/RichTextEditor.vue'
6
+ import { useAppContextStore } from '@/stores/appContext'
7
+ import type { FooterBlock } from '@dev.smartpricing/message-composer-utils/types'
8
+
9
+ defineOptions({
10
+ name: 'EmailTranslatorFooter',
11
+ })
12
+
13
+ const { baseBlock } = defineProps<{
14
+ baseBlock: FooterBlock
15
+ }>()
16
+
17
+ const targetBlock = defineModel<FooterBlock>({
18
+ required: true,
19
+ })
20
+
21
+ const appContext = useAppContextStore()
22
+
23
+ // Generate HTML for preview of base language
24
+ function generatePreviewHtml(content: JSONContent | undefined): string {
25
+ if (!content) return ''
26
+ return generateHTML(content, getTipTapExtensions({ preset: 'email' })).replaceAll(
27
+ '<p></p>',
28
+ '<p>&nbsp;</p>',
29
+ )
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div class="flex gap-8 items-start" :data-testid="emailTranslatorFooterTestIds.root">
35
+ <!-- Base language preview (read-only) -->
36
+ <div class="flex-1 min-w-0">
37
+ <div
38
+ class="paragraph-sm text-primary-700 break-words"
39
+ v-html="generatePreviewHtml(baseBlock.settings.content)"
40
+ :data-testid="emailTranslatorFooterTestIds.basePreview"
41
+ />
42
+ </div>
43
+
44
+ <!-- Translation editor (editable) -->
45
+ <div class="flex-1 min-w-0 flex gap-2 items-start">
46
+ <div class="flex-1">
47
+ <RichTextEditor
48
+ v-model:text="targetBlock.settings.content"
49
+ preset="email"
50
+ bold
51
+ italic
52
+ underline
53
+ lists
54
+ horizontal-rule
55
+ alignment
56
+ link
57
+ emoji
58
+ dynamic-values
59
+ :data-testid="emailTranslatorFooterTestIds.contentEditor"
60
+ />
61
+ </div>
62
+
63
+ <slot name="actions" />
64
+ </div>
65
+ </div>
66
+ </template>
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { GridBlock, EmailBlock } from '@dev.smartpricing/message-composer-utils/types'
4
+ import HeadingTranslator from './Heading.vue'
5
+ import ParagraphTranslator from './Paragraph.vue'
6
+ import ButtonTranslator from './Button.vue'
7
+ import ImageTranslator from './Image.vue'
8
+
9
+ defineOptions({
10
+ name: 'EmailTranslatorGrid',
11
+ })
12
+
13
+ const { baseBlock, blockIndex } = defineProps<{
14
+ baseBlock: GridBlock
15
+ targetBlock: GridBlock
16
+ blockIndex?: number
17
+ }>()
18
+
19
+ const targetBlock = defineModel<GridBlock>({
20
+ required: true,
21
+ })
22
+
23
+ // Component registry for nested translators
24
+ const translatorComponents: Record<string, any> = {
25
+ heading: HeadingTranslator,
26
+ paragraph: ParagraphTranslator,
27
+ button: ButtonTranslator,
28
+ image: ImageTranslator,
29
+ }
30
+
31
+ // Extract all translatable nested blocks with their positions
32
+ interface NestedBlockInfo {
33
+ columnIndex: number
34
+ blockIndex: number
35
+ baseBlock: EmailBlock
36
+ targetBlock: EmailBlock
37
+ label: string
38
+ }
39
+
40
+ const nestedTranslatableBlocks = computed<NestedBlockInfo[]>(() => {
41
+ const blocks: NestedBlockInfo[] = []
42
+
43
+ // Build ID-based map of target columns
44
+ const targetColumnsMap = new Map(
45
+ targetBlock.value.settings.columnData.map((col) => [col.id, col]),
46
+ )
47
+
48
+ for (let colIdx = 0; colIdx < baseBlock.settings.columnData.length; colIdx++) {
49
+ const baseColumn = baseBlock.settings.columnData[colIdx]
50
+ if (!baseColumn) continue
51
+ const targetColumn = targetColumnsMap.get(baseColumn.id)
52
+
53
+ if (!targetColumn) continue
54
+
55
+ // Build ID-based map of target nested blocks within this column
56
+ const targetNestedMap = new Map(targetColumn.blocks.map((b) => [b.id, b]))
57
+
58
+ for (let blkIdx = 0; blkIdx < baseColumn.blocks.length; blkIdx++) {
59
+ const baseNested = baseColumn.blocks[blkIdx]
60
+ if (!baseNested) continue
61
+ const targetNested = targetNestedMap.get(baseNested.id)
62
+
63
+ if (!targetNested) continue
64
+
65
+ // Only include translatable blocks
66
+ if (
67
+ baseNested.type === 'heading' ||
68
+ baseNested.type === 'paragraph' ||
69
+ baseNested.type === 'button' ||
70
+ baseNested.type === 'image'
71
+ ) {
72
+ const gridLabel = blockIndex !== undefined ? `Grid ${blockIndex + 1}` : 'Grid'
73
+ blocks.push({
74
+ columnIndex: colIdx,
75
+ blockIndex: blkIdx,
76
+ baseBlock: baseNested,
77
+ targetBlock: targetNested,
78
+ label: `${gridLabel} → Column ${colIdx + 1} → ${baseNested.type}`,
79
+ })
80
+ }
81
+ }
82
+ }
83
+
84
+ return blocks
85
+ })
86
+ </script>
87
+
88
+ <template>
89
+ <div class="space-y-6" :data-testid="emailTranslatorGridTestIds.root">
90
+ <div
91
+ v-for="(info, idx) in nestedTranslatableBlocks"
92
+ :key="`${info.columnIndex}-${info.blockIndex}`"
93
+ class="space-y-2"
94
+ :data-testid="`${emailTranslatorGridTestIds.nestedBlock}-${info.columnIndex}-${info.blockIndex}`"
95
+ >
96
+ <UBadge
97
+ variant="subtle"
98
+ color="neutral"
99
+ size="sm"
100
+ :data-testid="`${emailTranslatorGridTestIds.nestedBlockBadge}-${info.columnIndex}-${info.blockIndex}`"
101
+ >
102
+ {{ info.label }}
103
+ </UBadge>
104
+
105
+ <component
106
+ :is="translatorComponents[info.baseBlock.type]"
107
+ v-if="translatorComponents[info.baseBlock.type]"
108
+ :base-block="info.baseBlock"
109
+ v-model="info.targetBlock"
110
+ >
111
+ <template #actions>
112
+ <slot name="actions" :block="info" />
113
+ </template>
114
+ </component>
115
+
116
+ <UDivider v-if="idx < nestedTranslatableBlocks.length - 1" />
117
+ </div>
118
+
119
+ <div
120
+ v-if="nestedTranslatableBlocks.length === 0"
121
+ class="text-gray-500 text-sm italic"
122
+ :data-testid="emailTranslatorGridTestIds.emptyState"
123
+ >
124
+ No translatable blocks in grid
125
+ </div>
126
+ </div>
127
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ import type { HeaderBlock } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { computed } from 'vue'
4
+ import { useI18n } from 'vue-i18n'
5
+ import MediaUploadOrSelect from '@/components/Common/MediaUploadOrSelect.vue'
6
+ import { deepClone } from '@dev.smartpricing/message-composer-utils/utils'
7
+
8
+ defineOptions({
9
+ name: 'EmailTranslatorHeader',
10
+ })
11
+
12
+ const { baseBlock } = defineProps<{
13
+ baseBlock: HeaderBlock
14
+ }>()
15
+
16
+ const targetBlock = defineModel<HeaderBlock>({ required: true })
17
+
18
+ const { t } = useI18n()
19
+
20
+ const isCustom = computed(() => targetBlock.value.settings.src !== baseBlock.settings.src)
21
+
22
+ function resetToMain() {
23
+ const resetBlock = deepClone(toRaw(baseBlock))
24
+ targetBlock.value.settings.src = resetBlock.settings.src
25
+ targetBlock.value.settings.alt = resetBlock.settings.alt
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div class="space-y-3" :data-testid="emailTranslatorHeaderTestIds.root">
31
+ <!-- Image row -->
32
+ <div class="flex gap-8 items-start">
33
+ <!-- Base language preview (read-only) -->
34
+ <div class="flex-1 min-w-0">
35
+ <div v-if="baseBlock.settings.src" class="max-w-[200px]">
36
+ <img
37
+ :src="baseBlock.settings.src"
38
+ class="rounded border border-muted max-h-[120px] object-contain"
39
+ :data-testid="emailTranslatorHeaderTestIds.basePreviewImage"
40
+ />
41
+ </div>
42
+ <div v-else class="paragraph-sm text-muted">
43
+ {{ t('editor.email.blocks.header.name') }}
44
+ </div>
45
+ </div>
46
+
47
+ <!-- Translation editor (editable) -->
48
+ <div class="flex-1 min-w-0">
49
+ <div class="flex items-center gap-2">
50
+ <MediaUploadOrSelect
51
+ v-model="targetBlock.settings.src"
52
+ media-type="image"
53
+ class="flex-1"
54
+ show-preview
55
+ :data-testid="emailTranslatorHeaderTestIds.mediaUpload"
56
+ >
57
+ <template #actions>
58
+ <UTooltip :text="t('editor.whatsapp.translator.reset_to_main')" v-if="isCustom">
59
+ <UButton
60
+ color="warning"
61
+ variant="ghost"
62
+ icon="ph:arrow-counter-clockwise-thin"
63
+ @click.stop="resetToMain"
64
+ :data-testid="emailTranslatorHeaderTestIds.resetButton"
65
+ />
66
+ </UTooltip>
67
+ </template>
68
+ </MediaUploadOrSelect>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <!-- Alt text row -->
74
+ <div class="flex gap-8 items-start" v-if="baseBlock.settings.alt">
75
+ <div class="flex-1 min-w-0">
76
+ <p class="paragraph-sm text-muted truncate">
77
+ {{ baseBlock.settings.alt || '' }}
78
+ </p>
79
+ </div>
80
+ <div class="flex-1 min-w-0">
81
+ <UInput
82
+ v-model="targetBlock.settings.alt"
83
+ :placeholder="t('editor.email.blocks.header.settings.alt_placeholder')"
84
+ size="sm"
85
+ :data-testid="emailTranslatorHeaderTestIds.altInput"
86
+ />
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import type { JSONContent } from '@tiptap/vue-3'
3
+ import { generateHTML } from '@tiptap/vue-3'
4
+ import { getTipTapExtensions } from '@dev.smartpricing/message-composer-utils/utils'
5
+ import RichTextEditor from '@/components/Editor/RichTextEditor.vue'
6
+ import { useAppContextStore } from '@/stores/appContext'
7
+ import type { HeadingBlock } from '@dev.smartpricing/message-composer-utils/types'
8
+
9
+ defineOptions({
10
+ name: 'EmailTranslatorHeading',
11
+ })
12
+
13
+ const { baseBlock } = defineProps<{
14
+ baseBlock: HeadingBlock
15
+ }>()
16
+
17
+ const targetBlock = defineModel<HeadingBlock>({
18
+ required: true,
19
+ })
20
+
21
+ const appContext = useAppContextStore()
22
+
23
+ // Generate HTML for preview of base language
24
+ function generatePreviewHtml(content: JSONContent | undefined): string {
25
+ if (!content) return ''
26
+ return generateHTML(content, getTipTapExtensions({ preset: 'email' })).replaceAll(
27
+ '<p></p>',
28
+ '<p>&nbsp;</p>',
29
+ )
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div class="flex gap-8 items-start" :data-testid="emailTranslatorHeadingTestIds.root">
35
+ <!-- Base language preview (read-only) -->
36
+ <div class="flex-1 min-w-0">
37
+ <div
38
+ class="paragraph-sm text-primary-700 break-words"
39
+ v-html="generatePreviewHtml(baseBlock.settings.content)"
40
+ :data-testid="emailTranslatorHeadingTestIds.basePreview"
41
+ />
42
+ </div>
43
+
44
+ <!-- Translation editor (editable) -->
45
+ <div class="flex-1 min-w-0 flex gap-2 items-start">
46
+ <div class="flex-1">
47
+ <RichTextEditor
48
+ v-model:text="targetBlock.settings.content"
49
+ preset="email"
50
+ bold
51
+ italic
52
+ underline
53
+ lists
54
+ horizontal-rule
55
+ alignment
56
+ link
57
+ emoji
58
+ dynamic-values
59
+ :data-testid="emailTranslatorHeadingTestIds.contentEditor"
60
+ />
61
+ </div>
62
+
63
+ <slot name="actions" />
64
+ </div>
65
+ </div>
66
+ </template>
@@ -0,0 +1,91 @@
1
+ <script setup lang="ts">
2
+ import type { ImageBlock } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { computed } from 'vue'
4
+ import { useI18n } from 'vue-i18n'
5
+ import MediaUploadOrSelect from '@/components/Common/MediaUploadOrSelect.vue'
6
+ import { deepClone } from '@dev.smartpricing/message-composer-utils/utils'
7
+
8
+ defineOptions({
9
+ name: 'EmailTranslatorImage',
10
+ })
11
+
12
+ const { baseBlock } = defineProps<{
13
+ baseBlock: ImageBlock
14
+ }>()
15
+
16
+ const targetBlock = defineModel<ImageBlock>({ required: true })
17
+
18
+ const { t } = useI18n()
19
+
20
+ const isCustom = computed(() => targetBlock.value.settings.src !== baseBlock.settings.src)
21
+
22
+ function resetToMain() {
23
+ const resetBlock = deepClone(toRaw(baseBlock))
24
+ targetBlock.value.settings.src = resetBlock.settings.src
25
+ targetBlock.value.settings.href = resetBlock.settings.href
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div class="space-y-3" :data-testid="emailTranslatorImageTestIds.root">
31
+ <!-- Image row -->
32
+ <div class="flex gap-8 items-start">
33
+ <!-- Base language preview (read-only) -->
34
+ <div class="flex-1 min-w-0">
35
+ <div v-if="baseBlock.settings.src" class="max-w-[200px]">
36
+ <img
37
+ :src="baseBlock.settings.src"
38
+ class="rounded border border-muted max-h-[120px] object-contain"
39
+ :data-testid="emailTranslatorImageTestIds.basePreviewImage"
40
+ />
41
+ </div>
42
+ <div v-else class="paragraph-sm text-muted">
43
+ {{ t('editor.email.blocks.image.name') }}
44
+ </div>
45
+ </div>
46
+
47
+ <!-- Translation editor (editable) -->
48
+ <div class="flex-1 min-w-0">
49
+ <div class="flex items-center gap-2">
50
+ <MediaUploadOrSelect
51
+ v-model="targetBlock.settings.src"
52
+ media-type="image"
53
+ class="flex-1"
54
+ show-preview
55
+ :data-testid="emailTranslatorImageTestIds.mediaUpload"
56
+ >
57
+ <template #actions>
58
+ <UTooltip :text="t('editor.whatsapp.translator.reset_to_main')" v-if="isCustom">
59
+ <UButton
60
+ color="warning"
61
+ variant="ghost"
62
+ icon="ph:arrow-counter-clockwise-thin"
63
+ @click.stop="resetToMain"
64
+ :data-testid="emailTranslatorImageTestIds.resetButton"
65
+ />
66
+ </UTooltip>
67
+ </template>
68
+ </MediaUploadOrSelect>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <!-- Link row -->
74
+ <div class="flex gap-8 items-start" v-if="baseBlock.settings.href">
75
+ <div class="flex-1 min-w-0">
76
+ <p class="paragraph-sm text-muted truncate">
77
+ {{ baseBlock.settings.href || '' }}
78
+ </p>
79
+ </div>
80
+ <div class="flex-1 min-w-0">
81
+ <UInput
82
+ v-model="targetBlock.settings.href"
83
+ type="url"
84
+ :placeholder="t('editor.email.blocks.image.settings.href_placeholder')"
85
+ size="sm"
86
+ :data-testid="emailTranslatorImageTestIds.hrefInput"
87
+ />
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </template>
@@ -0,0 +1,138 @@
1
+ <script setup lang="ts">
2
+ import { computed, type Component } from 'vue'
3
+ import type {
4
+ EmailMessage,
5
+ EmailBlock,
6
+ PostEntity,
7
+ EmailBlockType,
8
+ } from '@dev.smartpricing/message-composer-utils/types'
9
+ import { getLanguageDisplayName } from '@dev.smartpricing/message-composer-utils/utils'
10
+ import { useI18n } from 'vue-i18n'
11
+ import HeadingTranslator from './Heading.vue'
12
+ import ParagraphTranslator from './Paragraph.vue'
13
+ import ButtonTranslator from './Button.vue'
14
+ import DiscountTranslator from './Discount.vue'
15
+ import FooterTranslator from './Footer.vue'
16
+ import RawHtmlTranslator from './RawHtml.vue'
17
+ import GridTranslator from './Grid.vue'
18
+ import UnsubscribeLinkTranslator from './UnsubscribeLink.vue'
19
+ import ImageTranslator from './Image.vue'
20
+ import HeaderTranslator from './Header.vue'
21
+
22
+ defineOptions({
23
+ name: 'EmailTranslator',
24
+ })
25
+
26
+ const { baseMessage, baseLanguage, targetLanguage, filteredBlockIds } = defineProps<{
27
+ baseMessage: EmailMessage | PostEntity<EmailMessage>
28
+ baseLanguage: string
29
+ targetLanguage: string
30
+ filteredBlockIds?: string[]
31
+ }>()
32
+
33
+ const targetMessage = defineModel<EmailMessage | PostEntity<EmailMessage>>({
34
+ required: true,
35
+ })
36
+
37
+ const { locale } = useI18n()
38
+
39
+ // Component registry
40
+ const translatorComponents: Partial<Record<EmailBlockType, Component>> = {
41
+ heading: HeadingTranslator,
42
+ paragraph: ParagraphTranslator,
43
+ button: ButtonTranslator,
44
+ discount: DiscountTranslator,
45
+ footer: FooterTranslator,
46
+ raw_html: RawHtmlTranslator,
47
+ grid: GridTranslator,
48
+ unsubscribe_link: UnsubscribeLinkTranslator,
49
+ image: ImageTranslator,
50
+ header: HeaderTranslator,
51
+ }
52
+
53
+ // Get translatable blocks with indices
54
+ interface BlockInfo {
55
+ baseBlock: EmailBlock
56
+ targetBlock: EmailBlock
57
+ type: string
58
+ }
59
+
60
+ const translatableBlocks = computed<BlockInfo[]>(() => {
61
+ // Build ID-based map of target blocks for quick lookup
62
+ const targetBlocksMap = new Map(targetMessage.value.body.blocks.map((b) => [b.id, b]))
63
+
64
+ return baseMessage.body.blocks
65
+ .map((block) => {
66
+ const targetBlock = targetBlocksMap.get(block.id)
67
+ if (!targetBlock) return
68
+
69
+ return {
70
+ baseBlock: block,
71
+ targetBlock,
72
+ type: block.type,
73
+ }
74
+ })
75
+ .filter((block) => {
76
+ if (!block) return false
77
+ if (!translatorComponents[block.type]) return false
78
+ if (filteredBlockIds && !filteredBlockIds.includes(block.baseBlock.id)) return false
79
+ return true
80
+ })
81
+ })
82
+
83
+ // Get block type display name
84
+ function getBlockTypeLabel(type: string): string {
85
+ const t = useI18n().t
86
+ const key = `editor.email.blocks.${type}.name`
87
+ return t(key)
88
+ }
89
+ </script>
90
+
91
+ <template>
92
+ <div class="space-y-2" :data-testid="emailTranslatorIndexTestIds.root">
93
+ <!-- Translation cards for each block -->
94
+ <div
95
+ v-for="info in translatableBlocks"
96
+ :key="info.baseBlock.id"
97
+ class="bg-white border border-muted rounded-lg overflow-hidden"
98
+ :data-testid="`${emailTranslatorIndexTestIds.blockCard}-${info.baseBlock.id}`"
99
+ >
100
+ <!-- Card header with block title -->
101
+ <div class="px-4 py-3 border-b border-muted">
102
+ <h4 class="label-sm">
103
+ {{ getBlockTypeLabel(info.type) }}
104
+ </h4>
105
+ </div>
106
+
107
+ <!-- Card content -->
108
+ <div class="px-px">
109
+ <!-- Column headers -->
110
+ <div class="bg-muted px-4 py-2 flex gap-8">
111
+ <div class="flex-1 min-w-0">
112
+ <div class="flex items-center gap-1">
113
+ <p class="label-xs text-primary-700">
114
+ {{ getLanguageDisplayName(baseLanguage, locale) }}
115
+ </p>
116
+ </div>
117
+ </div>
118
+ <div class="flex-1 min-w-0">
119
+ <div class="flex items-center gap-1">
120
+ <p class="label-xs text-primary-700">
121
+ {{ getLanguageDisplayName(targetLanguage, locale) }}
122
+ </p>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Content row -->
128
+ <div class="px-4 py-3 show-list">
129
+ <component
130
+ :is="translatorComponents[info.type]"
131
+ :base-block="info.baseBlock"
132
+ v-model="info.targetBlock"
133
+ />
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import type { JSONContent } from '@tiptap/vue-3'
3
+ import { generateHTML } from '@tiptap/vue-3'
4
+ import { getTipTapExtensions } from '@dev.smartpricing/message-composer-utils/utils'
5
+ import RichTextEditor from '@/components/Editor/RichTextEditor.vue'
6
+ import { useAppContextStore } from '@/stores/appContext'
7
+ import type { ParagraphBlock } from '@dev.smartpricing/message-composer-utils/types'
8
+
9
+ defineOptions({
10
+ name: 'EmailTranslatorParagraph',
11
+ })
12
+
13
+ const { baseBlock } = defineProps<{
14
+ baseBlock: ParagraphBlock
15
+ }>()
16
+
17
+ const targetBlock = defineModel<ParagraphBlock>({
18
+ required: true,
19
+ })
20
+
21
+ const appContext = useAppContextStore()
22
+
23
+ // Generate HTML for preview of base language
24
+ function generatePreviewHtml(content: JSONContent | undefined): string {
25
+ if (!content) return ''
26
+ return generateHTML(content, getTipTapExtensions({ preset: 'email' })).replaceAll(
27
+ '<p></p>',
28
+ '<p>&nbsp;</p>',
29
+ )
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div class="flex gap-8 items-start" :data-testid="emailTranslatorParagraphTestIds.root">
35
+ <!-- Base language preview (read-only) -->
36
+ <div class="flex-1 min-w-0">
37
+ <div
38
+ class="paragraph-sm text-primary-700 break-words"
39
+ v-html="generatePreviewHtml(baseBlock.settings.content)"
40
+ :data-testid="emailTranslatorParagraphTestIds.basePreview"
41
+ />
42
+ </div>
43
+
44
+ <!-- Translation editor (editable) -->
45
+ <div class="flex-1 min-w-0 flex gap-2 items-start">
46
+ <div class="flex-1">
47
+ <RichTextEditor
48
+ v-model:text="targetBlock.settings.content"
49
+ preset="email"
50
+ bold
51
+ italic
52
+ underline
53
+ lists
54
+ horizontal-rule
55
+ alignment
56
+ link
57
+ emoji
58
+ dynamic-values
59
+ :data-testid="emailTranslatorParagraphTestIds.contentEditor"
60
+ />
61
+ </div>
62
+
63
+ <slot name="actions" />
64
+ </div>
65
+ </div>
66
+ </template>