@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.
- package/README.md +75 -0
- package/app/api/ai.ts +28 -0
- package/app/api/brands.ts +54 -0
- package/app/api/client.ts +56 -0
- package/app/api/compilation.ts +34 -0
- package/app/api/dynamicValues.ts +11 -0
- package/app/api/index.ts +11 -0
- package/app/api/languages.ts +6 -0
- package/app/api/media.ts +26 -0
- package/app/api/messageGroups.ts +49 -0
- package/app/api/messages.ts +22 -0
- package/app/api/meta.ts +18 -0
- package/app/api/tracking.ts +9 -0
- package/app/components/Brand/BrandDrawer.vue +281 -0
- package/app/components/Brand/BrandPreview.vue +137 -0
- package/app/components/Brand/Steps/BrandStepGeneral.vue +37 -0
- package/app/components/Brand/Steps/BrandStepSocials.vue +48 -0
- package/app/components/Brand/Steps/BrandStepVisual.vue +214 -0
- package/app/components/Common/BrandColorSwatches.vue +37 -0
- package/app/components/Common/ColorInput.vue +144 -0
- package/app/components/Common/EmojiPickerButton.vue +35 -0
- package/app/components/Common/FontFamilySelect.vue +39 -0
- package/app/components/Common/HtmlPreviewIframe.vue +68 -0
- package/app/components/Common/IncompleteTranslationsDialog.vue +97 -0
- package/app/components/Common/LanguageList.vue +356 -0
- package/app/components/Common/LoadingOverlay.vue +12 -0
- package/app/components/Common/MediaUploadOrSelect.vue +299 -0
- package/app/components/Common/MessagePreviewSlideover.vue +88 -0
- package/app/components/Common/MonacoEditor.vue +110 -0
- package/app/components/Common/PhoneInput.vue +804 -0
- package/app/components/Common/SectionHeader.vue +26 -0
- package/app/components/Common/TranslationActionBar.vue +139 -0
- package/app/components/Common/ViewLessMore.vue +40 -0
- package/app/components/CreateTemplateButton.vue +59 -0
- package/app/components/DynamicDataButton.vue +75 -0
- package/app/components/DynamicDataSlideover.vue +149 -0
- package/app/components/Editor/AiGenerateSlideover.vue +292 -0
- package/app/components/Editor/EditorLinkPopover.vue +154 -0
- package/app/components/Editor/RichTextEditor.vue +495 -0
- package/app/components/Email/Editor/BlockLibrary.vue +89 -0
- package/app/components/Email/Editor/BlockSettings.vue +60 -0
- package/app/components/Email/Editor/Blocks/ButtonBlock.vue +117 -0
- package/app/components/Email/Editor/Blocks/ButtonSettings.vue +228 -0
- package/app/components/Email/Editor/Blocks/DiscountCodeBlock.vue +74 -0
- package/app/components/Email/Editor/Blocks/DiscountCodeSettings.vue +162 -0
- package/app/components/Email/Editor/Blocks/DividerBlock.vue +31 -0
- package/app/components/Email/Editor/Blocks/DividerSettings.vue +108 -0
- package/app/components/Email/Editor/Blocks/FooterBlock.vue +61 -0
- package/app/components/Email/Editor/Blocks/FooterSettings.vue +123 -0
- package/app/components/Email/Editor/Blocks/GridBlock.vue +128 -0
- package/app/components/Email/Editor/Blocks/GridSettings.vue +109 -0
- package/app/components/Email/Editor/Blocks/HTMLBlock.vue +31 -0
- package/app/components/Email/Editor/Blocks/HTMLSettings.vue +85 -0
- package/app/components/Email/Editor/Blocks/HeaderBlock.vue +53 -0
- package/app/components/Email/Editor/Blocks/HeaderSettings.vue +113 -0
- package/app/components/Email/Editor/Blocks/HeadingBlock.vue +61 -0
- package/app/components/Email/Editor/Blocks/HeadingSettings.vue +156 -0
- package/app/components/Email/Editor/Blocks/ImageBlock.vue +49 -0
- package/app/components/Email/Editor/Blocks/ImageSettings.vue +53 -0
- package/app/components/Email/Editor/Blocks/IndexBlock.vue +155 -0
- package/app/components/Email/Editor/Blocks/ParagraphBlock.vue +61 -0
- package/app/components/Email/Editor/Blocks/ParagraphSettings.vue +157 -0
- package/app/components/Email/Editor/Blocks/SocialsBlock.vue +63 -0
- package/app/components/Email/Editor/Blocks/SocialsSettings.vue +117 -0
- package/app/components/Email/Editor/Blocks/SpacerBlock.vue +37 -0
- package/app/components/Email/Editor/Blocks/SpacerSettings.vue +95 -0
- package/app/components/Email/Editor/Blocks/TopProductsBlock.vue +47 -0
- package/app/components/Email/Editor/Blocks/TopProductsSettings.vue +87 -0
- package/app/components/Email/Editor/Blocks/UnsubscribeLinkBlock.vue +61 -0
- package/app/components/Email/Editor/Blocks/UnsubscribeLinkSettings.vue +123 -0
- package/app/components/Email/Editor/Canvas.vue +143 -0
- package/app/components/Email/Editor/GeneralStyleSettings.vue +125 -0
- package/app/components/Email/Editor/Index.vue +77 -0
- package/app/components/Email/Editor/LeftSidebar.vue +95 -0
- package/app/components/Email/InlinePreview.vue +124 -0
- package/app/components/Email/Preview.vue +126 -0
- package/app/components/Email/PreviewPanel.vue +57 -0
- package/app/components/Email/SendTestEmailModal.vue +121 -0
- package/app/components/Email/TranslationEditor.vue +198 -0
- package/app/components/Email/Translator/Button.vue +101 -0
- package/app/components/Email/Translator/Discount.vue +60 -0
- package/app/components/Email/Translator/Footer.vue +66 -0
- package/app/components/Email/Translator/Grid.vue +127 -0
- package/app/components/Email/Translator/Header.vue +90 -0
- package/app/components/Email/Translator/Heading.vue +66 -0
- package/app/components/Email/Translator/Image.vue +91 -0
- package/app/components/Email/Translator/Index.vue +138 -0
- package/app/components/Email/Translator/Paragraph.vue +66 -0
- package/app/components/Email/Translator/RawHtml.vue +64 -0
- package/app/components/Email/Translator/UnsubscribeLink.vue +59 -0
- package/app/components/TemplateList/BaseTable.vue +354 -0
- package/app/components/TemplateList/StatusCell.vue +67 -0
- package/app/components/Whatsapp/CategoryChanger.vue +63 -0
- package/app/components/Whatsapp/Editor/Body.vue +53 -0
- package/app/components/Whatsapp/Editor/Buttons.vue +341 -0
- package/app/components/Whatsapp/Editor/Footer.vue +70 -0
- package/app/components/Whatsapp/Editor/Header.vue +156 -0
- package/app/components/Whatsapp/Editor/Index.vue +89 -0
- package/app/components/Whatsapp/Editor/SyncWrapper.vue +166 -0
- package/app/components/Whatsapp/Preview.vue +197 -0
- package/app/components/Whatsapp/PreviewPanel.vue +24 -0
- package/app/components/Whatsapp/TranslationEditor.vue +150 -0
- package/app/components/Whatsapp/Translator/Body.vue +61 -0
- package/app/components/Whatsapp/Translator/Button.vue +209 -0
- package/app/components/Whatsapp/Translator/Footer.vue +56 -0
- package/app/components/Whatsapp/Translator/Header.vue +61 -0
- package/app/components/Whatsapp/Translator/HeaderMedia.vue +117 -0
- package/app/components/Whatsapp/Translator/Index.vue +159 -0
- package/app/composables/composerContext.ts +35 -0
- package/app/composables/useAutoTranslate.ts +242 -0
- package/app/composables/useAutoTranslateEmail.ts +294 -0
- package/app/composables/useChangeTracking.ts +212 -0
- package/app/composables/useChangeTrackingEmail.ts +227 -0
- package/app/composables/useChannels.ts +36 -0
- package/app/composables/useEmailBlockContext.ts +43 -0
- package/app/composables/useExitConfirmation.ts +39 -0
- package/app/composables/useGoBack.ts +15 -0
- package/app/composables/useIncompleteTranslations.ts +55 -0
- package/app/composables/useIncompleteTranslationsEmail.ts +56 -0
- package/app/composables/useMediaLimits.ts +42 -0
- package/app/composables/useTemplateFormValidation.ts +109 -0
- package/app/composables/useTemplateOperations.ts +147 -0
- package/app/composables/useTracking.ts +39 -0
- package/app/composables/useTranslationFilters.ts +176 -0
- package/app/composables/useTranslationFiltersEmail.ts +244 -0
- package/app/composables/useWhatsappTemplateBehaviors.ts +58 -0
- package/app/middleware/channel-guard.ts +13 -0
- package/app/pages/brands.vue +195 -0
- package/app/pages/images.vue +141 -0
- package/app/pages/index.vue +198 -0
- package/app/pages/preview/[id].vue +47 -0
- package/app/pages/template/email/[id].vue +596 -0
- package/app/pages/template/email/create.vue +562 -0
- package/app/pages/template/whatsapp/[id].vue +561 -0
- package/app/pages/template/whatsapp/create.vue +468 -0
- package/app/plugins/admin-shortcut.client.ts +7 -0
- package/app/plugins/tracking.client.ts +12 -0
- package/app/queries/ai.ts +20 -0
- package/app/queries/brands.ts +75 -0
- package/app/queries/compilation.ts +37 -0
- package/app/queries/dynamicValues.ts +13 -0
- package/app/queries/index.ts +9 -0
- package/app/queries/languages.ts +10 -0
- package/app/queries/media.ts +27 -0
- package/app/queries/messageGroups.ts +112 -0
- package/app/queries/messages.ts +42 -0
- package/app/queries/meta.ts +13 -0
- package/app/stores/adminStore.ts +50 -0
- package/app/stores/appContext.ts +99 -0
- package/app/stores/emailEditor.ts +220 -0
- package/app/stores/main.ts +3 -0
- package/app/stores/metaSubmission.ts +116 -0
- package/app/types/app-config.d.ts +14 -0
- package/app/types/hooks.d.ts +9 -0
- package/app/types/tracking.ts +148 -0
- package/app/utils/locale.ts +17 -0
- package/app/utils/notifyParent.ts +7 -0
- package/app/utils/resolveDefaultLanguage.ts +14 -0
- package/app/utils/socialDefaults.ts +42 -0
- package/app/utils/testIds.const.ts +728 -0
- package/app.config.ts +5 -0
- package/i18n/check-translations.ts +56 -0
- package/i18n/locales/de.ts +834 -0
- package/i18n/locales/en.ts +826 -0
- package/i18n/locales/es.ts +833 -0
- package/i18n/locales/fr.ts +837 -0
- package/i18n/locales/it.ts +831 -0
- package/nuxt.config.ts +65 -0
- 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>
|