@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,137 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ createTipTapContent,
4
+ generateHtml,
5
+ type BrandCascadeData,
6
+ } from '@dev.smartpricing/message-composer-utils/utils'
7
+ import type {
8
+ ButtonBlock,
9
+ EmailMessage,
10
+ PostEntity,
11
+ } from '@dev.smartpricing/message-composer-utils/types'
12
+ import {
13
+ DEFAULT_PARAGRAPH_SETTINGS,
14
+ DEFAULT_BUTTON_SETTINGS,
15
+ DEFAULT_HEADING_SETTINGS,
16
+ emptyEmailBlock,
17
+ } from '@dev.smartpricing/message-composer-utils/types'
18
+ import { useDebounceFn } from '@vueuse/core'
19
+ import HtmlPreviewIframe from '~/components/Common/HtmlPreviewIframe.vue'
20
+ import { useBrandPreviewMutation } from '~/queries'
21
+
22
+ defineOptions({ name: 'BrandPreview' })
23
+
24
+ const props = defineProps<{
25
+ brand: BrandCascadeData
26
+ }>()
27
+
28
+ const { t } = useI18n()
29
+
30
+ const { mutate: compilePreview, data: compiled, isLoading: isCompiling } = useBrandPreviewMutation()
31
+
32
+ // Sample email message used for preview — settings are empty to exercise brand cascade
33
+ const sampleMessage: PostEntity<EmailMessage> = {
34
+ render_type: 'email_v1',
35
+ language_id: 'en',
36
+ message_group_id: 'brand-preview',
37
+ status: 'draft',
38
+ user_id: 'preview',
39
+ body: {
40
+ subject: 'Brand Preview',
41
+ blocks: [
42
+ emptyEmailBlock('header'),
43
+ {
44
+ ...emptyEmailBlock('heading'),
45
+ settings: {
46
+ ...DEFAULT_HEADING_SETTINGS,
47
+ content: createTipTapContent('<p>Lorem ipsum
dolor</p>', { preset: 'email' }),
48
+ },
49
+ },
50
+ {
51
+ ...emptyEmailBlock('paragraph'),
52
+ settings: {
53
+ ...DEFAULT_PARAGRAPH_SETTINGS,
54
+ content: createTipTapContent(
55
+ '<p>Lorem ipsum dolor sit amet consectetur. Nibh vel at egestas dictumst vel justo felis justo. Magna facilisi convallis non nulla. Lobortis nunc amet eget amet vitae. Scelerisque elit pretium netus praesent id eget eget interdum. </p>',
56
+ { preset: 'email' },
57
+ ),
58
+ },
59
+ },
60
+ {
61
+ ...emptyEmailBlock('button'),
62
+ settings: {
63
+ ...DEFAULT_BUTTON_SETTINGS,
64
+ content: createTipTapContent('BUTTON', { preset: 'email' }),
65
+ },
66
+ },
67
+ emptyEmailBlock('socials'),
68
+ emptyEmailBlock('footer'),
69
+ emptyEmailBlock('divider'),
70
+ emptyEmailBlock('unsubscribe_link'),
71
+ ],
72
+ settings: {},
73
+ },
74
+ metadata: {},
75
+ }
76
+
77
+ const iframeContent = ref('')
78
+
79
+ const debouncedCompile = useDebounceFn(() => {
80
+ compilePreview({
81
+ message: sampleMessage,
82
+ brand: props.brand,
83
+ })
84
+ }, 500)
85
+
86
+ // Watch brand data for changes and recompile
87
+ watch(
88
+ () => props.brand,
89
+ () => {
90
+ debouncedCompile()
91
+ },
92
+ { deep: true, immediate: true },
93
+ )
94
+
95
+ // Update iframe when compilation succeeds
96
+ watch(compiled, (result) => {
97
+ if (result?.render_type === 'email_v1') {
98
+ iframeContent.value = result.html
99
+ }
100
+ })
101
+ </script>
102
+
103
+ <template>
104
+ <div class="flex flex-col h-full">
105
+ <div class="flex-1 flex items-start justify-center p-4 overflow-auto">
106
+ <!-- Loading skeleton -->
107
+ <div v-if="isCompiling && !iframeContent" class="w-full space-y-2">
108
+ <USkeleton class="h-6 w-1/3 bg-white" />
109
+ <USkeleton class="h-4 bg-white" />
110
+ <USkeleton class="h-4 w-5/8 bg-white" />
111
+ <USkeleton class="h-32 bg-white" />
112
+ <USkeleton class="h-12 bg-white" />
113
+ </div>
114
+
115
+ <!-- Preview iframe -->
116
+ <div v-else-if="iframeContent" class="w-full relative">
117
+ <div
118
+ v-if="isCompiling"
119
+ class="absolute inset-0 bg-white/50 flex items-center justify-center z-10"
120
+ >
121
+ <UIcon name="i-heroicons-arrow-path" class="animate-spin h-5 w-5 text-neutral-400" />
122
+ </div>
123
+ <HtmlPreviewIframe
124
+ :html="iframeContent"
125
+ title="Brand preview"
126
+ :min-height="400"
127
+ class="border-0 bg-white w-full"
128
+ />
129
+ </div>
130
+
131
+ <!-- Empty state -->
132
+ <div v-else class="flex items-center justify-center text-neutral-400 text-sm py-12">
133
+ {{ t('pages.brands.preview.empty') }}
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import type { Property } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { usePropertiesQuery } from '~/queries'
4
+
5
+ const name = defineModel<string>('name', { required: true })
6
+ const selectedPropertyIds = defineModel<string[]>('selectedPropertyIds', { required: true })
7
+
8
+ const { data: properties } = usePropertiesQuery()
9
+
10
+ const propertyItems = computed(() =>
11
+ (properties.value ?? []).map((p: Property) => ({
12
+ label: p.name,
13
+ value: p.id,
14
+ })),
15
+ )
16
+ </script>
17
+
18
+ <template>
19
+ <UFormField :label="$t('pages.brands.fields.name')" required>
20
+ <UInput
21
+ v-model="name"
22
+ :placeholder="$t('pages.brands.fields.name_placeholder')"
23
+ class="w-full"
24
+ />
25
+ </UFormField>
26
+
27
+ <UFormField :label="$t('pages.brands.fields.properties')">
28
+ <USelectMenu
29
+ v-model="selectedPropertyIds"
30
+ :items="propertyItems"
31
+ multiple
32
+ value-key="value"
33
+ :placeholder="$t('pages.brands.fields.properties_placeholder')"
34
+ class="w-full"
35
+ />
36
+ </UFormField>
37
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import type { BrandSocials, SocialPlatform } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { SOCIAL_PLATFORMS } from '@dev.smartpricing/message-composer-utils/types'
4
+ import { isValidUrl } from '@dev.smartpricing/message-composer-utils/utils'
5
+
6
+ const socials = defineModel<BrandSocials>({ required: true })
7
+
8
+ function socialLabel(platform: SocialPlatform): string {
9
+ return platform.charAt(0).toUpperCase() + platform.slice(1)
10
+ }
11
+
12
+ function updateUrl(platform: SocialPlatform, value: string) {
13
+ const trimmed = value.trim()
14
+ const updated = { ...socials.value }
15
+ if (trimmed) {
16
+ updated[platform] = trimmed
17
+ } else {
18
+ delete updated[platform]
19
+ }
20
+ socials.value = updated
21
+ }
22
+
23
+ function hasError(value: string | undefined): boolean {
24
+ return !!value && !isValidUrl(value)
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <div class="space-y-4">
30
+ <div
31
+ v-for="platform in SOCIAL_PLATFORMS"
32
+ :key="platform"
33
+ class="flex items-center gap-3 p-3 rounded-lg border border-neutral-200"
34
+ >
35
+ <span class="text-sm font-medium w-24 shrink-0">
36
+ {{ socialLabel(platform) }}
37
+ </span>
38
+ <UInput
39
+ :model-value="socials?.[platform] ?? ''"
40
+ :placeholder="`https://${platform}.com/...`"
41
+ :color="hasError(socials?.[platform]) ? 'error' : undefined"
42
+ class="flex-1"
43
+ size="sm"
44
+ @update:model-value="updateUrl(platform, $event as string)"
45
+ />
46
+ </div>
47
+ </div>
48
+ </template>
@@ -0,0 +1,214 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ BrandDefaultEmailSettings,
4
+ BrandDefaultButtonSettings,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+ import ColorThief from 'colorthief'
7
+
8
+ import FontFamilySelect from '~/components/Common/FontFamilySelect.vue'
9
+ import ColorInput from '~/components/Common/ColorInput.vue'
10
+ import { useUploadMediaMutation } from '~/queries'
11
+ const logoMediaId = defineModel<string | null>('logoMediaId', { required: true })
12
+ const logoUrl = defineModel<string>('logoUrl', { required: true })
13
+ const colors = defineModel<string[]>('colors', { required: true })
14
+ const defaultEmailSettings = defineModel<BrandDefaultEmailSettings>('defaultEmailSettings', {
15
+ required: true,
16
+ })
17
+ const defaultButtonSettings = defineModel<BrandDefaultButtonSettings>('defaultButtonSettings', {
18
+ required: true,
19
+ })
20
+
21
+ // ---- Logo upload ----
22
+ const logoFileInput = ref<HTMLInputElement | null>(null)
23
+ const { mutateAsync: uploadMedia } = useUploadMediaMutation()
24
+ const isUploadingLogo = ref(false)
25
+ const isExtractingColors = ref(false)
26
+
27
+ function triggerLogoUpload() {
28
+ logoFileInput.value?.click()
29
+ }
30
+
31
+ async function extractColorsFromImage(file: File) {
32
+ isExtractingColors.value = true
33
+ const objectUrl = URL.createObjectURL(file)
34
+ try {
35
+ const img = new Image()
36
+ img.src = objectUrl
37
+ await new Promise<void>((resolve, reject) => {
38
+ img.onload = () => resolve()
39
+ img.onerror = reject
40
+ })
41
+ const colorThief = new ColorThief()
42
+ const palette = colorThief.getPalette(img, 4)
43
+ const extracted = new Set(['#FFFFFF', '#000000'])
44
+ for (const [r, g, b] of palette) {
45
+ const hex =
46
+ `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`.toUpperCase()
47
+ extracted.add(hex)
48
+ }
49
+ colors.value = [...extracted]
50
+ } catch (err) {
51
+ console.error('Failed to extract colors from logo:', err)
52
+ } finally {
53
+ URL.revokeObjectURL(objectUrl)
54
+ isExtractingColors.value = false
55
+ }
56
+ }
57
+
58
+ async function handleLogoFile(event: Event) {
59
+ const input = event.target as HTMLInputElement
60
+ const file = input.files?.[0]
61
+ if (!file) return
62
+
63
+ isUploadingLogo.value = true
64
+ try {
65
+ const result = await uploadMedia(file)
66
+ if (result) {
67
+ logoMediaId.value = result.id
68
+ logoUrl.value = result.remote_url
69
+ await extractColorsFromImage(file)
70
+ }
71
+ } finally {
72
+ isUploadingLogo.value = false
73
+ input.value = ''
74
+ }
75
+ }
76
+
77
+ function removeLogo() {
78
+ logoMediaId.value = null
79
+ logoUrl.value = ''
80
+ }
81
+
82
+ // ---- Color swatches ----
83
+ const newColor = ref('#3B82F6')
84
+
85
+ function addColor() {
86
+ const c = newColor.value.toUpperCase()
87
+ if (c && !colors.value.includes(c)) {
88
+ colors.value.push(c)
89
+ }
90
+ }
91
+
92
+ function removeColor(index: number) {
93
+ colors.value.splice(index, 1)
94
+ }
95
+ </script>
96
+
97
+ <template>
98
+ <!-- Logo -->
99
+ <UFormField :label="$t('pages.brands.fields.logo')">
100
+ <div class="space-y-2">
101
+ <div v-if="logoUrl" class="flex items-center gap-3">
102
+ <img
103
+ :src="logoUrl"
104
+ alt="Brand logo"
105
+ class="h-16 w-16 rounded border border-neutral-200 object-contain"
106
+ />
107
+ <UButton
108
+ icon="i-heroicons-trash"
109
+ size="xs"
110
+ variant="ghost"
111
+ color="error"
112
+ :label="$t('common.actions.remove')"
113
+ @click="removeLogo"
114
+ />
115
+ </div>
116
+ <UButton
117
+ icon="i-heroicons-arrow-up-tray"
118
+ variant="outline"
119
+ color="neutral"
120
+ :loading="isUploadingLogo"
121
+ :label="
122
+ logoUrl ? $t('pages.brands.fields.change_logo') : $t('pages.brands.fields.upload_logo')
123
+ "
124
+ @click="triggerLogoUpload"
125
+ />
126
+ <input
127
+ ref="logoFileInput"
128
+ type="file"
129
+ accept="image/*"
130
+ class="hidden"
131
+ @change="handleLogoFile"
132
+ />
133
+ </div>
134
+ </UFormField>
135
+
136
+ <!-- Colors -->
137
+ <UFormField :label="$t('pages.brands.fields.colors')">
138
+ <div class="space-y-3">
139
+ <div v-if="isExtractingColors" class="flex items-center gap-2 text-sm text-neutral-500">
140
+ <UIcon name="i-heroicons-arrow-path" class="animate-spin" />
141
+ {{ $t('pages.brands.fields.extracting_colors') }}
142
+ </div>
143
+ <div class="flex flex-wrap gap-2">
144
+ <div v-for="(color, idx) in colors" class="w-[200px]" :key="color">
145
+ <ColorInput :label="color" v-model="colors[idx]">
146
+ <template #trailing>
147
+ <UButton
148
+ @click.stop="removeColor(idx)"
149
+ icon="ph:trash-bold"
150
+ class="ml-auto"
151
+ square
152
+ variant="ghost"
153
+ color="error"
154
+ >
155
+ </UButton>
156
+ </template>
157
+ </ColorInput>
158
+ </div>
159
+ </div>
160
+ <div class="w-[200px]">
161
+ <ColorInput :label="$t('pages.brands.fields.add_color')" v-model="newColor">
162
+ <template #actions>
163
+ <UButton @click="addColor" :label="$t('pages.brands.fields.add_color')" size="xs" />
164
+ </template>
165
+ </ColorInput>
166
+ </div>
167
+ </div>
168
+ </UFormField>
169
+
170
+ <!-- Default email colors -->
171
+ <div class="space-y-2">
172
+ <p class="text-sm font-medium">{{ $t('pages.brands.fields.default_email_colors') }}</p>
173
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
174
+ <ColorInput
175
+ :brand-colors="colors"
176
+ v-model="defaultEmailSettings.backgroundColor"
177
+ :label="$t('pages.brands.fields.email_bg_color')"
178
+ />
179
+ <ColorInput
180
+ :brand-colors="colors"
181
+ v-model="defaultEmailSettings.contentBackgroundColor"
182
+ :label="$t('pages.brands.fields.email_content_bg_color')"
183
+ />
184
+ <ColorInput
185
+ :brand-colors="colors"
186
+ v-model="defaultEmailSettings.textColor"
187
+ :label="$t('pages.brands.fields.email_text_color')"
188
+ />
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Default button colors -->
193
+ <div class="space-y-2">
194
+ <p class="text-sm font-medium">{{ $t('pages.brands.fields.default_button_colors') }}</p>
195
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
196
+ <ColorInput
197
+ :brand-colors="colors"
198
+ v-model="defaultButtonSettings.textColor"
199
+ :label="$t('pages.brands.fields.button_text_color')"
200
+ />
201
+ <ColorInput
202
+ :brand-colors="colors"
203
+ v-model="defaultButtonSettings.backgroundColor"
204
+ :label="$t('pages.brands.fields.button_bg_color')"
205
+ />
206
+ </div>
207
+ </div>
208
+
209
+ <!-- Default font -->
210
+ <FontFamilySelect
211
+ v-model="defaultEmailSettings.fontFamily"
212
+ :label="$t('pages.brands.fields.default_font')"
213
+ />
214
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ colors: string[]
4
+ }>()
5
+
6
+ const emit = defineEmits<{
7
+ select: [color: string]
8
+ }>()
9
+
10
+ const selectedColor = defineModel<string | undefined>()
11
+
12
+ function selectColor(color: string) {
13
+ selectedColor.value = color
14
+ emit('select', color)
15
+ }
16
+ </script>
17
+
18
+ <template>
19
+ <div v-if="colors.length > 0" class="space-y-1">
20
+ <p class="text-xs text-neutral-500">{{ $t('editor.email.generalStyle.fromColorList') }}</p>
21
+ <div class="flex flex-wrap gap-1.5">
22
+ <button
23
+ v-for="color in colors"
24
+ :key="color"
25
+ class="h-7 w-7 rounded-full border-2 transition-all hover:scale-110"
26
+ :class="
27
+ selectedColor === color
28
+ ? 'border-primary-400 ring-2 ring-primary-200'
29
+ : 'border-neutral-200'
30
+ "
31
+ :style="{ backgroundColor: color }"
32
+ :title="color"
33
+ @click="selectColor(color)"
34
+ />
35
+ </div>
36
+ </div>
37
+ </template>
@@ -0,0 +1,144 @@
1
+ <script setup lang="ts">
2
+ const color = defineModel<string | undefined>()
3
+
4
+ const props = defineProps<{
5
+ label?: string
6
+ defaultValue?: string // Global default color (e.g., from globalSettings)
7
+ brandColors?: string[] // Brand color swatches
8
+ }>()
9
+
10
+ // Computed color to display (fallback to default if undefined)
11
+ const displayColor = computed(() => {
12
+ if (color.value === 'transparent') return '#FFFFFF00'
13
+ return color.value ?? props.defaultValue ?? '#000000'
14
+ })
15
+
16
+ // Computed chip color
17
+ const chip = computed(() => ({ backgroundColor: displayColor.value }))
18
+
19
+ // Track if using default (for visual indication)
20
+ const isUsingDefault = computed(() => color.value === undefined && props.defaultValue !== undefined)
21
+
22
+ // Internal color for picker (always has a value)
23
+ const pickerColor = ref(displayColor.value)
24
+
25
+ // Hex text input (without #)
26
+ const hexInput = ref(stripHash(displayColor.value))
27
+
28
+ // Watch display color to update picker when defaultValue changes externally
29
+ watch(
30
+ displayColor,
31
+ (newValue) => {
32
+ pickerColor.value = newValue
33
+ hexInput.value = stripHash(newValue)
34
+ },
35
+ { immediate: true },
36
+ )
37
+
38
+ // When picker changes, update model and sync hex input
39
+ watch(pickerColor, (newValue) => {
40
+ hexInput.value = stripHash(newValue)
41
+ if (newValue === props.defaultValue && isUsingDefault.value) {
42
+ return
43
+ }
44
+ color.value = newValue
45
+ })
46
+
47
+ // Live-update model when hex input is valid
48
+ watch(hexInput, (raw) => {
49
+ const expanded = expandHex(raw)
50
+ if (expanded) {
51
+ const fullHex = `#${expanded}`
52
+ pickerColor.value = fullHex
53
+ if (fullHex === props.defaultValue && isUsingDefault.value) return
54
+ color.value = fullHex
55
+ }
56
+ })
57
+
58
+ function stripHash(val: string): string {
59
+ return val.replace(/^#/, '').toUpperCase()
60
+ }
61
+
62
+ function expandHex(raw: string): string | null {
63
+ const cleaned = raw.replace(/^#/, '').toUpperCase()
64
+ if (!/^[0-9A-F]+$/i.test(cleaned)) return null
65
+ if (cleaned.length === 3) {
66
+ return cleaned
67
+ .split('')
68
+ .map((c) => c + c)
69
+ .join('')
70
+ }
71
+ if (cleaned.length === 4) {
72
+ return cleaned
73
+ .split('')
74
+ .map((c) => c + c)
75
+ .join('')
76
+ }
77
+ if (cleaned.length === 6 || cleaned.length === 8) {
78
+ return cleaned
79
+ }
80
+ return null
81
+ }
82
+
83
+ function onHexBlur() {
84
+ // Revert to current valid color if input is invalid
85
+ const expanded = expandHex(hexInput.value)
86
+ if (!expanded) {
87
+ hexInput.value = stripHash(displayColor.value)
88
+ }
89
+ }
90
+
91
+ // Convert transparent to alpha hex on init
92
+ if (color.value === 'transparent') {
93
+ color.value = '#FFFFFF00'
94
+ }
95
+ </script>
96
+
97
+ <template>
98
+ <UPopover :data-testid="colorInputTestIds.popover">
99
+ <UButton
100
+ :label="label"
101
+ color="neutral"
102
+ variant="outline"
103
+ class="w-full"
104
+ :data-testid="colorInputTestIds.triggerButton"
105
+ >
106
+ <template #leading>
107
+ <span
108
+ :style="chip"
109
+ class="size-8 rounded-full border"
110
+ :class="isUsingDefault ? 'border-primary-400 border-2' : 'border-neutral-300'"
111
+ />
112
+ </template>
113
+ <template #trailing>
114
+ <slot name="trailing"></slot>
115
+ </template>
116
+ </UButton>
117
+
118
+ <template #content>
119
+ <div class="p-2 space-y-2">
120
+ <CommonBrandColorSwatches
121
+ v-if="brandColors && brandColors.length > 0"
122
+ v-model="color"
123
+ :colors="brandColors"
124
+ />
125
+ <UColorPicker v-model="pickerColor" :data-testid="colorInputTestIds.picker" />
126
+ <UInput
127
+ v-model="hexInput"
128
+ placeholder="FF0000"
129
+ maxlength="9"
130
+ size="sm"
131
+ @blur="onHexBlur"
132
+ :data-testid="colorInputTestIds.hexInput"
133
+ >
134
+ <template #leading>
135
+ <span class="text-neutral-400 text-sm">#</span>
136
+ </template>
137
+ </UInput>
138
+ <div class="flex items-center justify-end gap-2" v-if="$slots['actions']">
139
+ <slot name="actions" />
140
+ </div>
141
+ </div>
142
+ </template>
143
+ </UPopover>
144
+ </template>
@@ -0,0 +1,35 @@
1
+ <script lang="ts" setup>
2
+ import { ref } from 'vue'
3
+ import EmojiPicker, { type EmojiExt } from 'vue3-emoji-picker'
4
+ import 'vue3-emoji-picker/css'
5
+
6
+ defineProps<{}>()
7
+
8
+ const emit = defineEmits<{
9
+ select: [emoji: EmojiExt]
10
+ }>()
11
+
12
+ const showEmojiPicker = ref(false)
13
+
14
+ function onSelectEmoji(emoji: EmojiExt) {
15
+ emit('select', emoji)
16
+ showEmojiPicker.value = false
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <UPopover v-model:open="showEmojiPicker" :data-testid="emojiPickerTestIds.popover">
22
+ <UButton
23
+ icon="ph-smiley-thin"
24
+ color="primary"
25
+ variant="ghost"
26
+ :data-testid="emojiPickerTestIds.triggerButton"
27
+ />
28
+
29
+ <template #content>
30
+ <div :data-testid="emojiPickerTestIds.picker">
31
+ <EmojiPicker disable-skin-tones :native="true" @select="onSelectEmoji" />
32
+ </div>
33
+ </template>
34
+ </UPopover>
35
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ EMAIL_SAFE_FONT_ITEMS,
4
+ type SafeFonts,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+
7
+ const fontFamily = defineModel<SafeFonts | undefined>({ required: true })
8
+
9
+ const props = defineProps<{
10
+ label: string
11
+ defaultValue?: SafeFonts
12
+ }>()
13
+
14
+ const displayValue = computed({
15
+ get: () => fontFamily.value ?? props.defaultValue ?? 'Arial',
16
+ set: (value: SafeFonts) => {
17
+ fontFamily.value = value
18
+ },
19
+ })
20
+
21
+ const isUsingDefault = computed(
22
+ () => fontFamily.value === undefined && props.defaultValue !== undefined,
23
+ )
24
+
25
+ defineOptions({
26
+ name: 'CommonFontFamilySelect',
27
+ })
28
+ </script>
29
+
30
+ <template>
31
+ <UFormField :label="label" :data-testid="fontFamilySelectTestIds.formField">
32
+ <USelect
33
+ v-model="displayValue"
34
+ :items="EMAIL_SAFE_FONT_ITEMS"
35
+ :class="isUsingDefault ? 'ring-2 ring-primary-400' : ''"
36
+ :data-testid="fontFamilySelectTestIds.select"
37
+ />
38
+ </UFormField>
39
+ </template>