@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,495 @@
1
+ <script lang="ts" setup>
2
+ import type { JSONContent, Editor } from '@tiptap/vue-3'
3
+ import type { EmojiExt } from 'vue3-emoji-picker'
4
+ import CharacterCount from '@tiptap/extension-character-count'
5
+ import TextAlign from '@tiptap/extension-text-align'
6
+ import Mention from '@tiptap/extension-mention'
7
+ import type {
8
+ GenerateTextWidgetType,
9
+ RefineTextResponse,
10
+ } from '@dev.smartpricing/message-composer-utils/types'
11
+ import { convertToWhatsappMarkdown } from '@dev.smartpricing/message-composer-utils/utils'
12
+ import EmojiPickerButton from '../Common/EmojiPickerButton.vue'
13
+ import DynamicDataSlideover from '../DynamicDataSlideover.vue'
14
+ import EditorLinkPopover from './EditorLinkPopover.vue'
15
+ import AiGenerateSlideover from './AiGenerateSlideover.vue'
16
+ import { useRefineTextMutation } from '~/queries'
17
+ import Emoji, { gitHubEmojis } from '@tiptap/extension-emoji'
18
+
19
+ const { t } = useI18n()
20
+
21
+ const props = withDefaults(
22
+ defineProps<{
23
+ preset: 'whatsapp' | 'email'
24
+ characterLimit?: number
25
+ dynamicValues?: boolean
26
+ defaultContext?: string
27
+ improve?: boolean
28
+ generate?: boolean
29
+ generateWidgetType?: GenerateTextWidgetType
30
+ generateContext?: string
31
+ bold?: boolean
32
+ italic?: boolean
33
+ underline?: boolean
34
+ lists?: boolean
35
+ horizontalRule?: boolean
36
+ alignment?: boolean
37
+ link?: boolean
38
+ emoji?: boolean
39
+ }>(),
40
+ {
41
+ bold: false,
42
+ italic: false,
43
+ underline: false,
44
+ lists: false,
45
+ horizontalRule: false,
46
+ alignment: false,
47
+ link: false,
48
+ emoji: false,
49
+ dynamicValues: false,
50
+ improve: false,
51
+ generate: false,
52
+ },
53
+ )
54
+
55
+ defineOptions({
56
+ name: 'RichTextEditor',
57
+ })
58
+
59
+ const text = defineModel<JSONContent | undefined>('text', {
60
+ required: true,
61
+ })
62
+
63
+ const emit = defineEmits<{
64
+ focusout: [FocusEvent]
65
+ 'update:html': [string]
66
+ }>()
67
+
68
+ // Build extensions based on preset
69
+ const extensions = computed(() => {
70
+ const exts = []
71
+
72
+ // Character count
73
+ if (props.characterLimit) {
74
+ exts.push(
75
+ CharacterCount.configure({
76
+ limit: props.characterLimit,
77
+ }),
78
+ )
79
+ }
80
+
81
+ // Mention for dynamic values - configure custom rendering
82
+ if (props.dynamicValues) {
83
+ exts.push(
84
+ Mention.configure({
85
+ renderText: (mention) => `{{${mention.node.attrs.label}}}`,
86
+ renderHTML: (mention) => `{{${mention.node.attrs.label || mention.node.attrs.id}}}`,
87
+ HTMLAttributes: {
88
+ class: 'dynamic-data',
89
+ },
90
+ deleteTriggerWithBackspace: true,
91
+ }),
92
+ )
93
+ }
94
+
95
+ if (props.alignment) {
96
+ exts.push(
97
+ TextAlign.configure({
98
+ types: ['paragraph'],
99
+ }),
100
+ )
101
+ }
102
+
103
+ // Add emoji support if enabled
104
+ if (props.emoji) {
105
+ exts.push(
106
+ Emoji.configure({
107
+ emojis: gitHubEmojis,
108
+ HTMLAttributes: {
109
+ class: 'tiptap-emoji',
110
+ },
111
+ suggestion: {
112
+ char: ':',
113
+ items: (props) => gitHubEmojis.filter((emoji) => emoji.name.includes(props.query)),
114
+ },
115
+ }),
116
+ )
117
+ }
118
+
119
+ return exts
120
+ })
121
+
122
+ // Mention configuration - disable built-in since we use custom
123
+
124
+ // StarterKit configuration based on preset
125
+ const starterKitConfig = computed(() => {
126
+ return {
127
+ bold: props.bold ? undefined : false,
128
+ italic: props.italic ? undefined : false,
129
+ underline: props.underline ? undefined : false,
130
+ link: props.link
131
+ ? {
132
+ openOnClick: false,
133
+ HTMLAttributes: {
134
+ class: 'text-primary-600 underline',
135
+ },
136
+ }
137
+ : false,
138
+ heading: false,
139
+ bulletList: props.lists ? undefined : false,
140
+ orderedList: props.lists ? undefined : false,
141
+ blockquote: false,
142
+ code: false,
143
+ codeBlock: false,
144
+ horizontalRule: props.horizontalRule ? undefined : false,
145
+ hardBreak: props.preset === 'email' ? undefined : false,
146
+ strike: false,
147
+ listItem: props.lists ? undefined : false,
148
+ }
149
+ })
150
+
151
+ // Editor reference
152
+ const editorRef = useTemplateRef<{ editor: Editor }>('editorRef')
153
+ const editor = computed(() => editorRef.value?.editor)
154
+
155
+ // Toolbar items - built from boolean props
156
+ const toolbarItems = computed(() => {
157
+ const items: any[][] = []
158
+
159
+ // Formatting group
160
+ const fmtGroup: any[] = []
161
+ if (props.bold) {
162
+ fmtGroup.push({
163
+ kind: 'mark',
164
+ mark: 'bold',
165
+ icon: 'ph:text-b',
166
+ tooltip: { text: t('editor.toolbar.bold') },
167
+ })
168
+ }
169
+ if (props.italic) {
170
+ fmtGroup.push({
171
+ kind: 'mark',
172
+ mark: 'italic',
173
+ icon: 'ph:text-italic',
174
+ tooltip: { text: t('editor.toolbar.italic') },
175
+ })
176
+ }
177
+ if (props.underline) {
178
+ fmtGroup.push({
179
+ kind: 'mark',
180
+ mark: 'underline',
181
+ icon: 'ph:text-underline',
182
+ tooltip: { text: t('editor.toolbar.underline') },
183
+ })
184
+ }
185
+ if (fmtGroup.length) items.push(fmtGroup)
186
+
187
+ // Lists group
188
+ if (props.lists) {
189
+ items.push([
190
+ {
191
+ kind: 'bulletList',
192
+ icon: 'ph:list-bullets',
193
+ tooltip: { text: t('editor.toolbar.bullet_list') },
194
+ },
195
+ {
196
+ kind: 'orderedList',
197
+ icon: 'ph:list-numbers',
198
+ tooltip: { text: t('editor.toolbar.ordered_list') },
199
+ },
200
+ ])
201
+ }
202
+
203
+ // Horizontal rule
204
+ if (props.horizontalRule) {
205
+ items.push([{ slot: 'horizontalRule' as const, icon: 'ph:minus' }])
206
+ }
207
+
208
+ // Alignment
209
+ if (props.alignment) {
210
+ items.push([
211
+ {
212
+ icon: 'ph:text-align-justify',
213
+ kind: 'textAlign',
214
+ items: [
215
+ {
216
+ kind: 'textAlign',
217
+ align: 'left',
218
+ icon: 'ph:text-align-left',
219
+ label: t('editor.toolbar.align_left'),
220
+ },
221
+ {
222
+ kind: 'textAlign',
223
+ align: 'center',
224
+ icon: 'ph:text-align-center',
225
+ label: t('editor.toolbar.align_center'),
226
+ },
227
+ {
228
+ kind: 'textAlign',
229
+ align: 'right',
230
+ icon: 'ph:text-align-right',
231
+ label: t('editor.toolbar.align_right'),
232
+ },
233
+ {
234
+ kind: 'textAlign',
235
+ align: 'justify',
236
+ icon: 'ph:text-align-justify',
237
+ label: t('editor.toolbar.align_justify'),
238
+ },
239
+ ],
240
+ tooltip: { text: t('editor.toolbar.text_align') },
241
+ },
242
+ ])
243
+ }
244
+
245
+ // Link
246
+ if (props.link) {
247
+ items.push([{ slot: 'link' as const, icon: 'ph:link' }])
248
+ }
249
+
250
+ // Emoji picker
251
+ if (props.emoji) {
252
+ items.push([{ slot: 'emoji' as const, icon: 'ph:smiley' }])
253
+ }
254
+
255
+ // Dynamic data slot
256
+ if (props.dynamicValues) {
257
+ items.push([{ slot: 'dynamicData' as const, icon: 'ph:brackets-curly' }])
258
+ }
259
+
260
+ // AI actions (improve / generate) — single popover trigger
261
+ if (props.improve || props.generate) {
262
+ items.push([{ slot: 'ai' as const, icon: 'ph:sparkle' }])
263
+ }
264
+
265
+ return items
266
+ })
267
+
268
+ // WhatsApp markdown-aware character count
269
+ // Meta counts the final markdown output (with *bold*, _italic_ marks),
270
+ // so we must display that count instead of TipTap's document-model count.
271
+ const whatsappCharCount = computed(() => {
272
+ if (props.preset !== 'whatsapp' || !props.characterLimit || !text.value) return 0
273
+ return convertToWhatsappMarkdown(text.value).length
274
+ })
275
+
276
+ // Handlers
277
+ function onSelectEmoji(emoji: EmojiExt) {
278
+ editor.value?.commands.insertContent(emoji.i)
279
+ }
280
+
281
+ function onSelectDynamicData(data: { value: string; label: string }) {
282
+ editor.value
283
+ ?.chain()
284
+ .focus()
285
+ .insertContent({
286
+ type: 'mention',
287
+ attrs: {
288
+ id: data.value,
289
+ label: data.value,
290
+ },
291
+ })
292
+ .run()
293
+ }
294
+
295
+ function insertHorizontalRule() {
296
+ editor.value?.chain().focus().insertContent({ type: 'horizontalRule' }).run()
297
+ }
298
+
299
+ function handleUpdate({ editor: ed }: { editor: Editor }) {
300
+ text.value = ed.getJSON()
301
+ emit('update:html', ed.getHTML())
302
+ }
303
+
304
+ function handleBlur({ event }: { event: FocusEvent }) {
305
+ emit('focusout', event)
306
+ }
307
+
308
+ // AI Improve text
309
+ const { mutateAsync: refineText, isLoading: isRefining } = useRefineTextMutation()
310
+
311
+ async function improveText() {
312
+ if (!editor.value) return
313
+
314
+ try {
315
+ const data = await refineText(editor.value.getHTML())
316
+ const refinedText = (data as RefineTextResponse)?.refinedText
317
+ if (refinedText) {
318
+ editor.value.commands.setContent(refinedText)
319
+ }
320
+ } catch (error) {
321
+ console.error(error)
322
+ }
323
+ }
324
+
325
+ // AI Generate text
326
+ const isGenerateOpen = ref(false)
327
+
328
+ function openGenerate() {
329
+ isGenerateOpen.value = true
330
+ }
331
+
332
+ function handleGenerateSelect(html: string) {
333
+ if (!editor.value) return
334
+ editor.value.commands.setContent(html)
335
+ // Trigger update so the model syncs
336
+ text.value = editor.value.getJSON()
337
+ emit('update:html', editor.value.getHTML())
338
+ }
339
+
340
+ // Exposed methods
341
+ function setFocus() {
342
+ editor.value?.commands.focus()
343
+ }
344
+
345
+ function getHtml() {
346
+ return editor.value?.getHTML() || ''
347
+ }
348
+
349
+ function isEmpty() {
350
+ return getHtml() === '' || editor.value?.isEmpty
351
+ }
352
+
353
+ defineExpose({
354
+ editor,
355
+ setFocus,
356
+ isEmpty,
357
+ getHtml,
358
+ })
359
+ </script>
360
+
361
+ <template>
362
+ <div :data-testid="richTextEditorTestIds.root">
363
+ <div class="rounded-md border border-accented overflow-hidden">
364
+ <UEditor
365
+ ref="editorRef"
366
+ v-slot="{ editor: ed }"
367
+ v-model="text"
368
+ content-type="json"
369
+ :image="false"
370
+ :extensions="extensions"
371
+ :starter-kit="starterKitConfig"
372
+ :on-update="handleUpdate"
373
+ :mention="false"
374
+ :on-blur="handleBlur"
375
+ :placeholder="$t('editor.placeholder.insert_text')"
376
+ :data-testid="richTextEditorTestIds.editor"
377
+ >
378
+ <UEditorToolbar
379
+ :editor="ed"
380
+ :items="toolbarItems"
381
+ layout="fixed"
382
+ class="bg-muted border-b border-accented flex-wrap"
383
+ :data-testid="richTextEditorTestIds.toolbar"
384
+ >
385
+ <template #link>
386
+ <EditorLinkPopover v-if="ed" :editor="ed" />
387
+ </template>
388
+
389
+ <template #horizontalRule>
390
+ <UButton
391
+ size="sm"
392
+ color="neutral"
393
+ variant="ghost"
394
+ icon="ph:minus"
395
+ :tooltip="{ text: $t('editor.toolbar.horizontal_rule') }"
396
+ :data-testid="richTextEditorTestIds.horizontalRuleButton"
397
+ @click="insertHorizontalRule"
398
+ />
399
+ </template>
400
+
401
+ <template #emoji>
402
+ <EmojiPickerButton @select="onSelectEmoji" />
403
+ </template>
404
+
405
+ <template #dynamicData>
406
+ <DynamicDataSlideover
407
+ :default-context="props.defaultContext"
408
+ @select="onSelectDynamicData"
409
+ />
410
+ </template>
411
+
412
+ <template #ai>
413
+ <UPopover>
414
+ <UButton
415
+ size="sm"
416
+ color="ai"
417
+ variant="outline"
418
+ icon="ph:sparkle"
419
+ :data-testid="richTextEditorTestIds.improveButton"
420
+ />
421
+ <template #content>
422
+ <div class="flex flex-col gap-1 p-1">
423
+ <UButton
424
+ v-if="improve"
425
+ size="sm"
426
+ variant="ghost"
427
+ :disabled="isEmpty() || isRefining"
428
+ :label="$t('editor.actions.improve_text')"
429
+ @click="improveText"
430
+ />
431
+ <AiGenerateSlideover
432
+ v-if="generate && generateWidgetType"
433
+ v-model:open="isGenerateOpen"
434
+ :widget-type="generateWidgetType"
435
+ :context="generateContext"
436
+ @select="handleGenerateSelect"
437
+ />
438
+ </div>
439
+ </template>
440
+ </UPopover>
441
+ </template>
442
+ </UEditorToolbar>
443
+ </UEditor>
444
+
445
+ <!-- Refining overlay -->
446
+ <div class="relative">
447
+ <div v-show="isRefining" class="absolute inset-0 bg-white z-10">
448
+ <p class="paragraph-sm text-rainbow px-4 py-3">
449
+ {{ $t('editor.actions.refining') }}
450
+ </p>
451
+ </div>
452
+ </div>
453
+ </div>
454
+
455
+ <!-- Character limit display -->
456
+ <p
457
+ v-if="editor && characterLimit"
458
+ class="paragraph-xs text-primary-700 flex justify-between pt-1.5 px-1"
459
+ >
460
+ <span>
461
+ {{ $t('editor.validation.character_limit') }}
462
+ </span>
463
+ <span>
464
+ {{ preset === 'whatsapp' ? whatsappCharCount : editor.storage.characterCount.characters() }}
465
+ / {{ characterLimit }}
466
+ </span>
467
+ </p>
468
+
469
+ <!-- AI Generate slideover -->
470
+ </div>
471
+ </template>
472
+
473
+ <style>
474
+ .ProseMirror {
475
+ padding: 1rem;
476
+ }
477
+
478
+ .tiptap-emoji img {
479
+ height: 1em;
480
+ width: 1em;
481
+ vertical-align: middle;
482
+ }
483
+
484
+ .ProseMirror ul {
485
+ list-style-type: unset;
486
+ }
487
+
488
+ .ProseMirror ol {
489
+ list-style-type: unset;
490
+ }
491
+
492
+ .ProseMirror li {
493
+ display: list-item;
494
+ }
495
+ </style>
@@ -0,0 +1,89 @@
1
+ <script lang="ts" setup>
2
+ import { useDragAndDrop } from '@formkit/drag-and-drop/vue'
3
+ import { emptyEmailBlock, type EmailBlock } from '@dev.smartpricing/message-composer-utils/types'
4
+
5
+ const { t } = useI18n()
6
+ const { mainLanguage } = storeToRefs(useAppContextStore())
7
+
8
+ const blockDefinitions = (): EmailBlock[] => [
9
+ emptyEmailBlock('spacer'),
10
+ emptyEmailBlock('grid'),
11
+ emptyEmailBlock('divider'),
12
+ emptyEmailBlock('heading'),
13
+ emptyEmailBlock('paragraph'),
14
+ emptyEmailBlock('image'),
15
+ emptyEmailBlock('button'),
16
+ emptyEmailBlock('discount'),
17
+ emptyEmailBlock('header'),
18
+ emptyEmailBlock('footer'),
19
+ emptyEmailBlock('socials'),
20
+ emptyEmailBlock('unsubscribe_link', mainLanguage.value),
21
+ emptyEmailBlock('top_products'),
22
+ emptyEmailBlock('raw_html'),
23
+ ]
24
+
25
+ const blockIcons: Record<EmailBlock['type'], string> = {
26
+ button: 'ph:cursor',
27
+ spacer: 'ph:arrows-in-line-vertical',
28
+ discount: 'ph:tag',
29
+ divider: 'ph:minus',
30
+ heading: 'ph:text-t',
31
+ paragraph: 'ph:text-aa',
32
+ image: 'ph:images',
33
+ grid: 'ph:grid-four',
34
+ raw_html: 'ph:code',
35
+ header: 'ph:parallelogram',
36
+ footer: 'ph:signature',
37
+ unsubscribe_link: 'ph:link-break',
38
+ socials: 'ph:share-network',
39
+ top_products: 'ph:storefront',
40
+ }
41
+
42
+ // Setup drag-and-drop with clone plugin for library
43
+ const [libraryRef, libraryValues] = useDragAndDrop(blockDefinitions(), {
44
+ group: 'mail-builder',
45
+ sortable: false, // Library items don't reorder
46
+ dragEffectAllowed: 'copy',
47
+ dragDropEffect: 'copy',
48
+ accepts: () => false,
49
+ onTransfer: () => {
50
+ libraryValues.value = blockDefinitions()
51
+ },
52
+ })
53
+
54
+ const emailEditorStore = useEmailEditorStore()
55
+
56
+ const emit = defineEmits<{
57
+ 'block-added': [payload: { type: string; adding_mode: string }]
58
+ }>()
59
+
60
+ function handleBlockClick(block: EmailBlock) {
61
+ emailEditorStore.addBlock(block)
62
+ emit('block-added', { type: block.type, adding_mode: 'Click' })
63
+ }
64
+
65
+ defineOptions({
66
+ name: 'EmailEditorBlockLibrary',
67
+ })
68
+ </script>
69
+
70
+ <template>
71
+ <div ref="libraryRef" class="space-y-2" :data-testid="blockLibraryTestIds.container">
72
+ <div
73
+ v-for="block in libraryValues"
74
+ :key="block.type"
75
+ :data-block-type="block.type"
76
+ class="flex w-full cursor-grab active:cursor-grabbing items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 text-left transition-colors hover:border-primary-500 hover:bg-primary-50"
77
+ :data-testid="`${blockLibraryTestIds.blockItem}-${block.type}`"
78
+ @click="handleBlockClick(block)"
79
+ >
80
+ <UAvatar :icon="blockIcons[block.type]" />
81
+
82
+ <div class="flex-1 paragraph-sm-bold truncate">
83
+ {{ $t(`editor.email.blocks.${block.type}.name`) }}
84
+ </div>
85
+
86
+ <UIcon name="i-heroicons-bars-3" class="size-5 text-neutral-400 shrink-0" />
87
+ </div>
88
+ </div>
89
+ </template>
@@ -0,0 +1,60 @@
1
+ <script lang="ts" setup>
2
+ import type { EmailBlock } from '@dev.smartpricing/message-composer-utils/types'
3
+ import SpacerSettings from './Blocks/SpacerSettings.vue'
4
+ import DividerSettings from './Blocks/DividerSettings.vue'
5
+ import ParagraphSettings from './Blocks/ParagraphSettings.vue'
6
+ import HeadingSettings from './Blocks/HeadingSettings.vue'
7
+ import HTMLSettings from './Blocks/HTMLSettings.vue'
8
+ import ImageSettings from './Blocks/ImageSettings.vue'
9
+ import ButtonSettings from './Blocks/ButtonSettings.vue'
10
+ import DiscountCodeSettings from './Blocks/DiscountCodeSettings.vue'
11
+ import GridSettings from './Blocks/GridSettings.vue'
12
+ import FooterSettings from './Blocks/FooterSettings.vue'
13
+ import UnsubscribeLinkSettings from './Blocks/UnsubscribeLinkSettings.vue'
14
+ import SocialsSettings from './Blocks/SocialsSettings.vue'
15
+ import HeaderSettings from './Blocks/HeaderSettings.vue'
16
+ import TopProductsSettings from './Blocks/TopProductsSettings.vue'
17
+
18
+ const props = defineProps<{
19
+ block: EmailBlock
20
+ }>()
21
+
22
+ // Component registry for settings panels
23
+ const settingsComponents = {
24
+ spacer: SpacerSettings,
25
+ divider: DividerSettings,
26
+ paragraph: ParagraphSettings,
27
+ heading: HeadingSettings,
28
+ raw_html: HTMLSettings,
29
+ image: ImageSettings,
30
+ button: ButtonSettings,
31
+ discount: DiscountCodeSettings,
32
+ grid: GridSettings,
33
+ footer: FooterSettings,
34
+ unsubscribe_link: UnsubscribeLinkSettings,
35
+ socials: SocialsSettings,
36
+ header: HeaderSettings,
37
+ top_products: TopProductsSettings,
38
+ } as const
39
+
40
+ const settingsComponent = computed(() => {
41
+ return settingsComponents[props.block.type as keyof typeof settingsComponents]
42
+ })
43
+
44
+ defineOptions({
45
+ name: 'EmailEditorBlockSettings',
46
+ })
47
+ </script>
48
+
49
+ <template>
50
+ <component
51
+ v-if="settingsComponent"
52
+ :is="settingsComponent"
53
+ :block="block as any"
54
+ :key="block.id"
55
+ :data-testid="blockSettingsTestIds.container"
56
+ />
57
+ <div v-else class="p-4 text-sm text-neutral-500" :data-testid="blockSettingsTestIds.notAvailable">
58
+ {{ $t('editor.email.sidebar.settingsNotAvailable', { type: block.type }) }}
59
+ </div>
60
+ </template>