@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,299 @@
1
+ <script lang="ts" setup>
2
+ import type { MediaFromDb, MediaType } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { useMediaQuery, useUploadMediaMutation } from '~/queries'
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ showPreview?: boolean
8
+ accept?: string
9
+ mediaType?: MediaType
10
+ limitations?: string
11
+ }>(),
12
+ {
13
+ showPreview: false,
14
+ accept: undefined,
15
+ mediaType: 'image',
16
+ limitations: undefined,
17
+ },
18
+ )
19
+
20
+ const emit = defineEmits<{
21
+ 'media-selected': [media: MediaFromDb]
22
+ }>()
23
+
24
+ const modelValue = defineModel<string>({
25
+ required: true,
26
+ })
27
+
28
+ const { t } = useI18n()
29
+
30
+ // Media limits (derived from mediaType, overridable via props)
31
+ const {
32
+ accept: derivedAccept,
33
+ limitationsText,
34
+ validateFile,
35
+ } = useMediaLimits(() => props.mediaType!)
36
+
37
+ const effectiveAccept = computed(() => props.accept ?? derivedAccept.value)
38
+ const effectiveLimitations = computed(() => props.limitations ?? limitationsText.value)
39
+
40
+ // Media queries
41
+ const { data: mediaList } = useMediaQuery()
42
+ const { mutateAsync: uploadMedia } = useUploadMediaMutation()
43
+
44
+ // Upload handling
45
+ const mediaFile = ref<File | null>(null)
46
+ const isUploading = ref(false)
47
+
48
+ async function handleFileUpload() {
49
+ try {
50
+ if (!mediaFile.value) return
51
+ if (!validateFile(mediaFile.value)) {
52
+ mediaFile.value = null
53
+ return
54
+ }
55
+ isUploading.value = true
56
+ const response = await uploadMedia(mediaFile.value)
57
+ if (response) {
58
+ modelValue.value = response.remote_url
59
+ emit('media-selected', response)
60
+ }
61
+ } finally {
62
+ isUploading.value = false
63
+ mediaFile.value = null
64
+ }
65
+ }
66
+
67
+ // Media selector
68
+ const searchQuery = ref('')
69
+
70
+ function matchesAccept(fileType: string): boolean {
71
+ const patterns = effectiveAccept.value.split(',').map((s) => s.trim())
72
+ return patterns.some((pattern) => {
73
+ if (pattern.endsWith('/*')) return fileType.startsWith(pattern.replace('/*', '/'))
74
+ return fileType === pattern
75
+ })
76
+ }
77
+
78
+ const filteredMedia = computed(() => {
79
+ if (!mediaList.value) return []
80
+
81
+ let list = mediaList.value.filter((media) => matchesAccept(media.file_type))
82
+
83
+ if (searchQuery.value) {
84
+ const query = searchQuery.value.toLowerCase()
85
+ list = list.filter((media) => media.file_name.toLowerCase().includes(query))
86
+ }
87
+
88
+ return list
89
+ })
90
+
91
+ const hasMediaInLibrary = computed(() => {
92
+ if (!mediaList.value) return false
93
+ return mediaList.value.some((media) => matchesAccept(media.file_type))
94
+ })
95
+
96
+ function selectMedia(media: MediaFromDb) {
97
+ modelValue.value = media.remote_url
98
+ emit('media-selected', media)
99
+ open.value = false
100
+ searchQuery.value = ''
101
+ }
102
+
103
+ function getBasename(url: string): string {
104
+ if (!url) return ''
105
+ try {
106
+ return decodeURIComponent(url.split('/').pop() || url)
107
+ } catch {
108
+ return url.split('/').pop() || url
109
+ }
110
+ }
111
+
112
+ defineOptions({
113
+ name: 'MediaUploadOrSelect',
114
+ })
115
+
116
+ const open = ref(false)
117
+
118
+ const uploadLabel = computed(() =>
119
+ isUploading.value ? t('common.imageUpload.uploading') : t('common.imageUpload.upload'),
120
+ )
121
+ </script>
122
+
123
+ <template>
124
+ <div class="space-y-4" :data-testid="mediaUploadTestIds.root">
125
+ <UAlert
126
+ v-if="effectiveLimitations"
127
+ color="neutral"
128
+ variant="subtle"
129
+ icon="ph:info"
130
+ :description="effectiveLimitations"
131
+ :data-testid="mediaUploadTestIds.limitationsAlert"
132
+ />
133
+
134
+ <!-- Upload Button -->
135
+ <UFileUpload
136
+ v-model="mediaFile"
137
+ :accept="effectiveAccept"
138
+ :upload-label="uploadLabel"
139
+ @change="handleFileUpload"
140
+ :data-testid="mediaUploadTestIds.fileUpload"
141
+ >
142
+ <template #file>
143
+ <USkeleton class="size-full" />
144
+ </template>
145
+ <template v-if="$slots.actions" #actions>
146
+ <slot name="actions" />
147
+ </template>
148
+ </UFileUpload>
149
+
150
+ <!-- Current Preview -->
151
+ <div v-if="modelValue && showPreview">
152
+ <img
153
+ v-if="mediaType === 'image'"
154
+ :src="modelValue"
155
+ alt=""
156
+ class="h-32 w-full rounded border border-neutral-200 object-contain p-2"
157
+ :data-testid="mediaUploadTestIds.previewImage"
158
+ />
159
+ <div
160
+ v-else
161
+ class="flex items-center gap-2 rounded border border-neutral-200 p-2 paragraph-sm text-primary-700"
162
+ :data-testid="mediaUploadTestIds.previewFile"
163
+ >
164
+ <UIcon :name="mediaType === 'video' ? 'ph:video' : 'ph:file'" class="h-5 w-5 shrink-0" />
165
+ <span class="truncate">{{ getBasename(modelValue) }}</span>
166
+ </div>
167
+ </div>
168
+
169
+ <!-- Media Selector Modal -->
170
+ <UModal
171
+ v-model:open="open"
172
+ close-icon="ph:x"
173
+ :close="{ 'data-testid': mediaUploadTestIds.modalCloseButton }"
174
+ :data-testid="mediaUploadTestIds.modal"
175
+ >
176
+ <UButton
177
+ v-if="hasMediaInLibrary"
178
+ variant="outline"
179
+ color="neutral"
180
+ :icon="
181
+ mediaType === 'image'
182
+ ? 'i-heroicons-photo'
183
+ : mediaType === 'video'
184
+ ? 'ph:video'
185
+ : 'ph:file'
186
+ "
187
+ block
188
+ @click="open = true"
189
+ :data-testid="mediaUploadTestIds.selectLibraryButton"
190
+ >
191
+ {{ $t('common.imageUpload.selectFromLibrary') }}
192
+ </UButton>
193
+ <template #content>
194
+ <UCard>
195
+ <template #header>
196
+ <div class="space-y-3">
197
+ <h3 class="text-lg font-medium">{{ $t('common.imageUpload.selectFromLibrary') }}</h3>
198
+ <UInput
199
+ v-model="searchQuery"
200
+ icon="i-heroicons-magnifying-glass"
201
+ :placeholder="$t('common.imageUpload.searchPlaceholder')"
202
+ :data-testid="mediaUploadTestIds.searchInput"
203
+ />
204
+ </div>
205
+ </template>
206
+
207
+ <div
208
+ v-if="!hasMediaInLibrary"
209
+ class="py-8 text-center text-neutral-500"
210
+ :data-testid="mediaUploadTestIds.emptyState"
211
+ >
212
+ {{ $t('common.imageUpload.noImages') }}
213
+ </div>
214
+
215
+ <div
216
+ v-else-if="filteredMedia.length === 0"
217
+ class="py-8 text-center text-neutral-500"
218
+ :data-testid="mediaUploadTestIds.noResultsState"
219
+ >
220
+ {{ $t('common.imageUpload.noResults') }}
221
+ </div>
222
+
223
+ <!-- Image grid -->
224
+ <UScrollArea
225
+ v-else-if="mediaType === 'image'"
226
+ v-slot="{ item: media, index }"
227
+ :items="filteredMedia"
228
+ orientation="vertical"
229
+ :virtualize="{
230
+ gap: 16,
231
+ lanes: 3,
232
+ estimateSize: 136,
233
+ }"
234
+ class="h-96 p-1"
235
+ :data-testid="mediaUploadTestIds.imageGrid"
236
+ >
237
+ <button
238
+ class="group relative w-full overflow-hidden rounded border-2 transition-all"
239
+ :class="
240
+ modelValue === media.remote_url
241
+ ? 'border-primary-500'
242
+ : 'border-neutral-200 hover:border-primary-500'
243
+ "
244
+ @click="selectMedia(media)"
245
+ :data-testid="`${mediaUploadTestIds.imageItem}-${media.id}`"
246
+ >
247
+ <img
248
+ :src="media.remote_url"
249
+ :alt="media.file_name"
250
+ class="h-32 w-full object-contain"
251
+ :loading="index > 8 ? 'lazy' : 'eager'"
252
+ />
253
+
254
+ <div
255
+ class="absolute inset-0 flex items-center justify-center bg-white/50 opacity-0 backdrop-blur-xs transition-opacity group-hover:opacity-100"
256
+ >
257
+ <p class="truncate text-xs" :title="media.file_name">
258
+ {{ media.file_name }}
259
+ </p>
260
+ </div>
261
+ </button>
262
+ </UScrollArea>
263
+
264
+ <!-- Video/Document list -->
265
+ <UScrollArea
266
+ v-else
267
+ v-slot="{ item: media }"
268
+ :items="filteredMedia"
269
+ orientation="vertical"
270
+ :virtualize="{
271
+ gap: 8,
272
+ lanes: 1,
273
+ estimateSize: 48,
274
+ }"
275
+ class="h-96 p-1"
276
+ :data-testid="mediaUploadTestIds.fileList"
277
+ >
278
+ <button
279
+ class="flex w-full items-center gap-3 rounded border-2 p-3 transition-all"
280
+ :class="
281
+ modelValue === media.remote_url
282
+ ? 'border-primary-500 bg-primary-50'
283
+ : 'border-neutral-200 hover:border-primary-500'
284
+ "
285
+ @click="selectMedia(media)"
286
+ :data-testid="`${mediaUploadTestIds.fileItem}-${media.id}`"
287
+ >
288
+ <UIcon
289
+ :name="mediaType === 'video' ? 'ph:video' : 'ph:file'"
290
+ class="h-5 w-5 shrink-0 text-primary-700"
291
+ />
292
+ <span class="truncate text-sm">{{ media.file_name }}</span>
293
+ </button>
294
+ </UScrollArea>
295
+ </UCard>
296
+ </template>
297
+ </UModal>
298
+ </div>
299
+ </template>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import type {
4
+ PostEntity,
5
+ MessageFromDb,
6
+ WhatsappMessage,
7
+ EmailMessage,
8
+ Message,
9
+ } from '@dev.smartpricing/message-composer-utils/types'
10
+ import WhatsappPreview from '@/components/Whatsapp/Preview.vue'
11
+ import EmailInlinePreview from '@/components/Email/InlinePreview.vue'
12
+ import { useEmailEditorStore } from '~/stores/emailEditor'
13
+
14
+ interface Props {
15
+ message: PostEntity<Message> | MessageFromDb | null | undefined
16
+ dynamicData?: Record<string, any>
17
+ }
18
+
19
+ const props = withDefaults(defineProps<Props>(), {
20
+ dynamicData: () => ({}),
21
+ })
22
+
23
+ const emit = defineEmits<{
24
+ opened: []
25
+ }>()
26
+
27
+ const emailEditorStore = useEmailEditorStore()
28
+
29
+ const isOpen = ref(false)
30
+
31
+ watch(isOpen, (val) => {
32
+ if (val) emit('opened')
33
+ })
34
+
35
+ // Determine which preview component to use based on render_type
36
+ const renderType = computed(() => {
37
+ if (!props.message) return null
38
+ return props.message.render_type
39
+ })
40
+
41
+ const isWhatsapp = computed(() => renderType.value === 'whatsapp_v1')
42
+ const isEmail = computed(() => renderType.value === 'email_v1')
43
+ </script>
44
+
45
+ <template>
46
+ <USlideover
47
+ v-model:open="isOpen"
48
+ :title="$t('translation_action_bar.preview')"
49
+ close-icon="ph:x"
50
+ v-if="message"
51
+ :ui="{
52
+ content: isWhatsapp ? '' : 'max-w-[90vw]',
53
+ body: 'p-0 sm:p-0',
54
+ }"
55
+ :close="{ 'data-testid': messagePreviewSlideoverTestIds.slideoverCloseButton }"
56
+ :data-testid="messagePreviewSlideoverTestIds.slideover"
57
+ >
58
+ <UButton
59
+ size="sm"
60
+ square
61
+ color="primary"
62
+ variant="outline"
63
+ :label="$t('translation_action_bar.preview_button')"
64
+ icon="ph:eye"
65
+ :ui="{
66
+ label: 'hidden xl:block',
67
+ }"
68
+ :data-testid="messagePreviewSlideoverTestIds.triggerButton"
69
+ />
70
+
71
+ <template #body>
72
+ <!-- WhatsApp Preview -->
73
+ <WhatsappPreview
74
+ v-if="isWhatsapp"
75
+ :message="message as WhatsappMessage"
76
+ :dynamic-data="dynamicData"
77
+ />
78
+
79
+ <!-- Email Inline Preview (client-driven, reflects unsaved changes) -->
80
+ <EmailInlinePreview
81
+ v-else-if="isEmail"
82
+ :message="message as EmailMessage"
83
+ :brand="emailEditorStore.brand"
84
+ :dynamic-data="dynamicData"
85
+ />
86
+ </template>
87
+ </USlideover>
88
+ </template>
@@ -0,0 +1,110 @@
1
+ <script setup lang="ts">
2
+ import * as monaco from 'monaco-editor'
3
+ import { onMounted, onBeforeUnmount, watch, ref } from 'vue'
4
+
5
+ interface Props {
6
+ modelValue: string
7
+ language?: string
8
+ theme?: 'vs-light' | 'vs-dark'
9
+ readonly?: boolean
10
+ lineNumbers?: 'on' | 'off'
11
+ minimap?: boolean
12
+ wordWrap?: 'on' | 'off'
13
+ scrollBeyondLastLine?: boolean
14
+ automaticLayout?: boolean
15
+ maxLength?: number
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ language: 'html',
20
+ theme: 'vs-light',
21
+ readonly: false,
22
+ lineNumbers: 'on',
23
+ minimap: false,
24
+ wordWrap: 'on',
25
+ scrollBeyondLastLine: false,
26
+ automaticLayout: true,
27
+ })
28
+
29
+ const emit = defineEmits<{
30
+ 'update:modelValue': [value: string]
31
+ }>()
32
+
33
+ const editorContainer = ref<HTMLDivElement | null>(null)
34
+ let editor: monaco.editor.IStandaloneCodeEditor | null = null
35
+
36
+ onMounted(() => {
37
+ if (!editorContainer.value) return
38
+
39
+ editor = monaco.editor.create(editorContainer.value, {
40
+ value: props.modelValue,
41
+ language: props.language,
42
+ theme: props.theme,
43
+ readOnly: props.readonly,
44
+ lineNumbers: props.lineNumbers,
45
+ minimap: { enabled: props.minimap },
46
+ wordWrap: props.wordWrap,
47
+ scrollBeyondLastLine: props.scrollBeyondLastLine,
48
+ automaticLayout: props.automaticLayout,
49
+ scrollbar: {
50
+ verticalScrollbarSize: 10,
51
+ horizontalScrollbarSize: 10,
52
+ },
53
+ })
54
+
55
+ // Listen for content changes
56
+ editor.onDidChangeModelContent(() => {
57
+ if (!editor) return
58
+ const value = editor.getValue()
59
+
60
+ // Apply max length constraint if specified
61
+ if (props.maxLength && value.length > props.maxLength) {
62
+ const truncated = value.substring(0, props.maxLength)
63
+ editor.setValue(truncated)
64
+ emit('update:modelValue', truncated)
65
+ } else {
66
+ emit('update:modelValue', value)
67
+ }
68
+ })
69
+ })
70
+
71
+ onBeforeUnmount(() => {
72
+ editor?.dispose()
73
+ })
74
+
75
+ // Watch for external changes to modelValue
76
+ watch(
77
+ () => props.modelValue,
78
+ (newValue) => {
79
+ if (editor && editor.getValue() !== newValue) {
80
+ editor.setValue(newValue)
81
+ }
82
+ },
83
+ )
84
+
85
+ // Watch for theme changes
86
+ watch(
87
+ () => props.theme,
88
+ (newTheme) => {
89
+ if (editor) {
90
+ monaco.editor.setTheme(newTheme)
91
+ }
92
+ },
93
+ )
94
+ </script>
95
+
96
+ <template>
97
+ <div
98
+ ref="editorContainer"
99
+ class="monaco-editor-container"
100
+ :data-testid="monacoEditorTestIds.container"
101
+ />
102
+ </template>
103
+
104
+ <style scoped>
105
+ .monaco-editor-container {
106
+ width: 100%;
107
+ height: 100%;
108
+ min-height: 200px;
109
+ }
110
+ </style>