@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,244 @@
|
|
|
1
|
+
import { computed, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
type EmailMessage,
|
|
4
|
+
type EmailBlock,
|
|
5
|
+
type PostEntity,
|
|
6
|
+
TRANSLATABLE_BLOCK_TYPES,
|
|
7
|
+
type TranslatableBlockType,
|
|
8
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
9
|
+
import { extractText, isEmailBlockEmpty } from '@dev.smartpricing/message-composer-utils/utils'
|
|
10
|
+
import { useChangeTrackingEmail } from './useChangeTrackingEmail'
|
|
11
|
+
|
|
12
|
+
interface EmailTranslationFiltersParams {
|
|
13
|
+
messages: Ref<Record<string, EmailMessage | PostEntity<EmailMessage>>>
|
|
14
|
+
currentLanguage: Ref<string>
|
|
15
|
+
baseLanguage: Ref<string>
|
|
16
|
+
messageGroupId?: Ref<string | number | undefined>
|
|
17
|
+
searchQuery: Ref<string>
|
|
18
|
+
showOnlyMissing: Ref<boolean>
|
|
19
|
+
showOnlyChanges: Ref<boolean>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Find block by ID recursively (including nested grid blocks)
|
|
24
|
+
*/
|
|
25
|
+
function findBlockById(blocks: EmailBlock[], blockId: string): EmailBlock | null {
|
|
26
|
+
for (const block of blocks) {
|
|
27
|
+
if (block.id === blockId) {
|
|
28
|
+
return block
|
|
29
|
+
}
|
|
30
|
+
if (block.type === 'grid') {
|
|
31
|
+
for (const column of block.settings.columnData) {
|
|
32
|
+
const found = findBlockById(column.blocks, blockId)
|
|
33
|
+
if (found) return found
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract searchable text from block
|
|
42
|
+
*/
|
|
43
|
+
function getBlockSearchText(block: EmailBlock): string {
|
|
44
|
+
switch (block.type) {
|
|
45
|
+
case 'heading':
|
|
46
|
+
case 'paragraph':
|
|
47
|
+
case 'button':
|
|
48
|
+
case 'discount':
|
|
49
|
+
case 'footer':
|
|
50
|
+
if (block.settings.content) {
|
|
51
|
+
return extractText(block.settings.content, 'email_v1')
|
|
52
|
+
}
|
|
53
|
+
return ''
|
|
54
|
+
case 'raw_html':
|
|
55
|
+
return block.settings.html || ''
|
|
56
|
+
default:
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useTranslationFiltersEmail(params: EmailTranslationFiltersParams) {
|
|
62
|
+
const {
|
|
63
|
+
messages,
|
|
64
|
+
currentLanguage,
|
|
65
|
+
baseLanguage,
|
|
66
|
+
messageGroupId,
|
|
67
|
+
searchQuery,
|
|
68
|
+
showOnlyMissing,
|
|
69
|
+
showOnlyChanges,
|
|
70
|
+
} = params
|
|
71
|
+
|
|
72
|
+
// Initialize change tracking if messageGroupId is provided
|
|
73
|
+
const changeTracking = computed(() => {
|
|
74
|
+
if (!messageGroupId?.value) return null
|
|
75
|
+
return useChangeTrackingEmail(messageGroupId.value)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a block matches the search query
|
|
80
|
+
*/
|
|
81
|
+
function matchesSearch(blockId: string, baseMsg: EmailMessage): boolean {
|
|
82
|
+
if (!searchQuery.value || !searchQuery.value.trim()) return true
|
|
83
|
+
|
|
84
|
+
const query = searchQuery.value.toLowerCase().trim()
|
|
85
|
+
const block = findBlockById(baseMsg.body.blocks, blockId)
|
|
86
|
+
|
|
87
|
+
if (!block) return false
|
|
88
|
+
|
|
89
|
+
const searchText = getBlockSearchText(block)
|
|
90
|
+
return searchText.toLowerCase().includes(query)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if a block has missing translation
|
|
95
|
+
*/
|
|
96
|
+
function hasMissingTranslation(blockId: string, targetMsg: EmailMessage): boolean {
|
|
97
|
+
const block = findBlockById(targetMsg.body.blocks, blockId)
|
|
98
|
+
if (!block) return true
|
|
99
|
+
|
|
100
|
+
return isEmailBlockEmpty(block)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a block has changes
|
|
105
|
+
*/
|
|
106
|
+
function hasChanges(blockId: string): boolean {
|
|
107
|
+
const tracking = changeTracking.value
|
|
108
|
+
if (!tracking || !currentLanguage.value) return false
|
|
109
|
+
|
|
110
|
+
const currentMsg = messages.value[currentLanguage.value]
|
|
111
|
+
if (!currentMsg) return false
|
|
112
|
+
|
|
113
|
+
const changedBlockIds = tracking.getChangedBlockIds(currentLanguage.value, currentMsg)
|
|
114
|
+
return changedBlockIds.includes(blockId)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get all translatable block IDs from a message, including parent grid blocks
|
|
119
|
+
*/
|
|
120
|
+
function getAllTranslatableBlockIds(message: EmailMessage): string[] {
|
|
121
|
+
const blockIds = new Set<string>()
|
|
122
|
+
|
|
123
|
+
function isTranslatableOrMedia(type: string): boolean {
|
|
124
|
+
return (
|
|
125
|
+
TRANSLATABLE_BLOCK_TYPES.includes(type as TranslatableBlockType) ||
|
|
126
|
+
type === 'image' ||
|
|
127
|
+
type === 'header'
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function collectBlockIds(blocks: EmailBlock[]) {
|
|
132
|
+
for (const block of blocks) {
|
|
133
|
+
if (isTranslatableOrMedia(block.type)) {
|
|
134
|
+
blockIds.add(block.id)
|
|
135
|
+
} else if (block.type === 'grid') {
|
|
136
|
+
// Check if grid has any translatable children
|
|
137
|
+
let hasTranslatableChildren = false
|
|
138
|
+
for (const column of block.settings.columnData) {
|
|
139
|
+
for (const nestedBlock of column.blocks) {
|
|
140
|
+
if (isTranslatableOrMedia(nestedBlock.type)) {
|
|
141
|
+
hasTranslatableChildren = true
|
|
142
|
+
blockIds.add(nestedBlock.id)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Add grid block ID if it has translatable children
|
|
147
|
+
if (hasTranslatableChildren) {
|
|
148
|
+
blockIds.add(block.id)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
collectBlockIds(message.body.blocks)
|
|
155
|
+
return Array.from(blockIds)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Filter blocks based on active filters
|
|
160
|
+
*/
|
|
161
|
+
const filteredBlockIds: ComputedRef<string[]> = computed(() => {
|
|
162
|
+
const baseMsg = messages.value[baseLanguage.value]
|
|
163
|
+
const targetMsg = messages.value[currentLanguage.value]
|
|
164
|
+
|
|
165
|
+
if (!baseMsg || !targetMsg) {
|
|
166
|
+
return []
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const allBlockIds = getAllTranslatableBlockIds(baseMsg)
|
|
170
|
+
|
|
171
|
+
return allBlockIds.filter((blockId) => {
|
|
172
|
+
// Search filter
|
|
173
|
+
if (searchQuery.value && !matchesSearch(blockId, baseMsg)) {
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Missing translations filter
|
|
178
|
+
if (showOnlyMissing.value && !hasMissingTranslation(blockId, targetMsg)) {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Changes filter
|
|
183
|
+
if (showOnlyChanges.value && !hasChanges(blockId)) {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return true
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Check if subject should be shown based on filters
|
|
193
|
+
*/
|
|
194
|
+
const showSubject: ComputedRef<boolean> = computed(() => {
|
|
195
|
+
const baseMsg = messages.value[baseLanguage.value]
|
|
196
|
+
const targetMsg = messages.value[currentLanguage.value]
|
|
197
|
+
|
|
198
|
+
if (!baseMsg || !targetMsg) return false
|
|
199
|
+
|
|
200
|
+
// Search filter
|
|
201
|
+
if (searchQuery.value) {
|
|
202
|
+
const query = searchQuery.value.toLowerCase().trim()
|
|
203
|
+
const subjectText = baseMsg.body.subject?.toLowerCase() || ''
|
|
204
|
+
if (!subjectText.includes(query)) {
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Missing translations filter
|
|
210
|
+
if (showOnlyMissing.value) {
|
|
211
|
+
if (targetMsg.body.subject && targetMsg.body.subject.trim() !== '') {
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Changes filter
|
|
217
|
+
if (showOnlyChanges.value) {
|
|
218
|
+
const tracking = changeTracking.value
|
|
219
|
+
if (!tracking || !currentLanguage.value) return false
|
|
220
|
+
if (!tracking.hasSubjectChanged(currentLanguage.value, targetMsg)) {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return true
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check if any filters are active
|
|
230
|
+
*/
|
|
231
|
+
const hasActiveFilters: ComputedRef<boolean> = computed(() => {
|
|
232
|
+
return (
|
|
233
|
+
(searchQuery.value && searchQuery.value.trim() !== '') ||
|
|
234
|
+
showOnlyMissing.value ||
|
|
235
|
+
showOnlyChanges.value
|
|
236
|
+
)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
filteredBlockIds,
|
|
241
|
+
showSubject,
|
|
242
|
+
hasActiveFilters,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Ref } from 'vue'
|
|
2
|
+
import type {
|
|
3
|
+
MessageGroupCategory,
|
|
4
|
+
WaButtonV1,
|
|
5
|
+
WhatsappMessage,
|
|
6
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
7
|
+
import type { PostEntity } from '@dev.smartpricing/message-composer-utils/types'
|
|
8
|
+
import { extractFromLocale } from '@/utils/locale'
|
|
9
|
+
|
|
10
|
+
interface UseWhatsappTemplateBehaviorsOptions {
|
|
11
|
+
category: Ref<MessageGroupCategory>
|
|
12
|
+
messages: Ref<Record<string, WhatsappMessage | PostEntity<WhatsappMessage>>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useWhatsappTemplateBehaviors(options: UseWhatsappTemplateBehaviorsOptions) {
|
|
16
|
+
const { messages } = options
|
|
17
|
+
|
|
18
|
+
// Watch for category changes
|
|
19
|
+
// watch(
|
|
20
|
+
// () => category.value,
|
|
21
|
+
// (newCategory, oldCategory) => {
|
|
22
|
+
// // Only add unsubscribe button if category changed to "marketing"
|
|
23
|
+
// if (newCategory === 'marketing' && oldCategory !== 'marketing') {
|
|
24
|
+
// addUnsubscribeButtonIfNotExists()
|
|
25
|
+
// }
|
|
26
|
+
// },
|
|
27
|
+
// { immediate: false },
|
|
28
|
+
// )
|
|
29
|
+
|
|
30
|
+
async function addUnsubscribeButtonIfNotExists() {
|
|
31
|
+
// Check all messages for unsubscribe buttons
|
|
32
|
+
for (const message of Object.values(messages.value)) {
|
|
33
|
+
if (message.render_type === 'whatsapp_v1') {
|
|
34
|
+
const buttons = message.body.buttons.buttons
|
|
35
|
+
const hasUnsubscribeButton = buttons.some((button) => button.type === 'unsubscribe')
|
|
36
|
+
|
|
37
|
+
// Add unsubscribe button if it doesn't exist
|
|
38
|
+
if (!hasUnsubscribeButton) {
|
|
39
|
+
const unsubscribeButton: WaButtonV1 = {
|
|
40
|
+
id: crypto.randomUUID(),
|
|
41
|
+
type: 'unsubscribe',
|
|
42
|
+
text: await extractFromLocale(
|
|
43
|
+
'editor.whatsapp.buttons.default_text.unsubscribe',
|
|
44
|
+
message.language_id,
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
buttons.push(unsubscribeButton)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
// Expose any functions if needed for manual triggering
|
|
56
|
+
ensureUnsubscribeButton: addUnsubscribeButtonIfNotExists,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
2
|
+
const { isChannelAllowed } = useChannels()
|
|
3
|
+
|
|
4
|
+
// Extract channel from route path: /template/(whatsapp|email)/...
|
|
5
|
+
const pathMatch = to.path.match(/\/template\/(whatsapp|email)/)
|
|
6
|
+
if (!pathMatch) return
|
|
7
|
+
|
|
8
|
+
const channel = pathMatch[1] as 'whatsapp' | 'email'
|
|
9
|
+
|
|
10
|
+
if (!isChannelAllowed(channel)) {
|
|
11
|
+
return navigateTo('/', { replace: true })
|
|
12
|
+
}
|
|
13
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { BrandFromDb } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import { formatDate } from '@dev.smartpricing/message-composer-utils/utils'
|
|
4
|
+
import type { TableColumn } from '#ui/types'
|
|
5
|
+
import { useBrandsQuery, useDeleteBrandMutation } from '~/queries'
|
|
6
|
+
import BrandDrawer from '~/components/Brand/BrandDrawer.vue'
|
|
7
|
+
|
|
8
|
+
definePageMeta({
|
|
9
|
+
name: 'brands',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
const { confirm } = useConfirm()
|
|
14
|
+
|
|
15
|
+
const { data: brands, isPending } = useBrandsQuery()
|
|
16
|
+
const { mutateAsync: deleteBrand } = useDeleteBrandMutation()
|
|
17
|
+
|
|
18
|
+
// Drawer state
|
|
19
|
+
const drawerOpen = ref(false)
|
|
20
|
+
const editingBrandId = ref<string | undefined>(undefined)
|
|
21
|
+
|
|
22
|
+
// Search
|
|
23
|
+
const search = ref('')
|
|
24
|
+
const filteredBrands = computed(() => {
|
|
25
|
+
if (!brands.value?.length) return []
|
|
26
|
+
if (!search.value.trim()) return brands.value
|
|
27
|
+
const query = search.value.toLowerCase()
|
|
28
|
+
return brands.value.filter((b) => b.name.toLowerCase().includes(query))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Table config
|
|
32
|
+
const columns: TableColumn<BrandFromDb>[] = [
|
|
33
|
+
{
|
|
34
|
+
id: 'logo',
|
|
35
|
+
header: '',
|
|
36
|
+
enableSorting: false,
|
|
37
|
+
meta: {
|
|
38
|
+
class: {
|
|
39
|
+
th: 'w-12',
|
|
40
|
+
td: 'w-12',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
accessorKey: 'name',
|
|
46
|
+
header: (ctx) => getSortableHeader(ctx, t('pages.brands.column_name')),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
accessorKey: 'created_at',
|
|
50
|
+
header: (ctx) => getSortableHeader(ctx, t('pages.brands.column_created')),
|
|
51
|
+
cell: ({ row }) => formatDate(row.original.created_at),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'actions',
|
|
55
|
+
enableSorting: false,
|
|
56
|
+
header: '',
|
|
57
|
+
meta: {
|
|
58
|
+
class: {
|
|
59
|
+
th: 'w-[66px]',
|
|
60
|
+
td: 'w-[66px]',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const columnPinning = ref({
|
|
67
|
+
right: ['actions'],
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const sorting = ref([
|
|
71
|
+
{
|
|
72
|
+
id: 'created_at',
|
|
73
|
+
desc: true,
|
|
74
|
+
},
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
function getRowActions(brand: BrandFromDb) {
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
label: t('common.actions.edit'),
|
|
81
|
+
icon: 'ph:pencil-simple-line',
|
|
82
|
+
onSelect: () => openEdit(brand),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
label: t('common.actions.delete'),
|
|
86
|
+
icon: 'ph:trash',
|
|
87
|
+
color: 'error' as const,
|
|
88
|
+
onSelect: () => askDelete(brand),
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function openCreate() {
|
|
94
|
+
editingBrandId.value = undefined
|
|
95
|
+
drawerOpen.value = true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function openEdit(brand: BrandFromDb) {
|
|
99
|
+
editingBrandId.value = brand.id
|
|
100
|
+
drawerOpen.value = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function askDelete(brand: BrandFromDb) {
|
|
104
|
+
confirm({
|
|
105
|
+
title: t('pages.brands.delete_confirm_title'),
|
|
106
|
+
message: t('pages.brands.delete_confirm_message', { name: brand.name }),
|
|
107
|
+
destructive: true,
|
|
108
|
+
confirmProps: {
|
|
109
|
+
label: t('common.actions.delete'),
|
|
110
|
+
},
|
|
111
|
+
cancelProps: {
|
|
112
|
+
label: t('common.actions.cancel'),
|
|
113
|
+
},
|
|
114
|
+
action: async () => {
|
|
115
|
+
await deleteBrand(brand.id)
|
|
116
|
+
useToast().add({
|
|
117
|
+
color: 'success',
|
|
118
|
+
title: t('common.success'),
|
|
119
|
+
description: t('pages.brands.brand_deleted', { name: brand.name }),
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleDrawerSaved() {
|
|
126
|
+
drawerOpen.value = false
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<template>
|
|
131
|
+
<SNavigationPage>
|
|
132
|
+
<template #header>
|
|
133
|
+
<SNavigationBarHeader :title="t('pages.brands.title')">
|
|
134
|
+
<template #actions>
|
|
135
|
+
<UButton icon="ph:plus" :label="t('pages.brands.new_brand')" @click="openCreate" />
|
|
136
|
+
</template>
|
|
137
|
+
</SNavigationBarHeader>
|
|
138
|
+
</template>
|
|
139
|
+
|
|
140
|
+
<div class="flex-1 space-y-2.5 relative min-h-0 flex flex-col">
|
|
141
|
+
<div class="flex gap-2">
|
|
142
|
+
<UInput
|
|
143
|
+
v-model="search"
|
|
144
|
+
class="max-w-xs"
|
|
145
|
+
leading-icon="ph:magnifying-glass-bold"
|
|
146
|
+
:placeholder="t('pages.brands.search_placeholder')"
|
|
147
|
+
type="search"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<UCard
|
|
152
|
+
:ui="{
|
|
153
|
+
root: 'flex-1 min-h-0 flex flex-col',
|
|
154
|
+
body: 'overflow-auto relative flex-1 p-0 sm:p-0 relative',
|
|
155
|
+
}"
|
|
156
|
+
>
|
|
157
|
+
<UTable
|
|
158
|
+
v-model:column-pinning="columnPinning"
|
|
159
|
+
v-model:sorting="sorting"
|
|
160
|
+
:data="filteredBrands"
|
|
161
|
+
:columns="columns"
|
|
162
|
+
:loading="isPending"
|
|
163
|
+
:empty="t('pages.brands.no_brands')"
|
|
164
|
+
sticky
|
|
165
|
+
class="flex-1 bg-white"
|
|
166
|
+
:ui="{
|
|
167
|
+
tr: 'hover:bg-muted',
|
|
168
|
+
root: 'overflow-visible static',
|
|
169
|
+
}"
|
|
170
|
+
>
|
|
171
|
+
<template #logo-cell="{ row }">
|
|
172
|
+
<img
|
|
173
|
+
v-if="row.original.logo_url"
|
|
174
|
+
:src="row.original.logo_url"
|
|
175
|
+
:alt="row.original.name"
|
|
176
|
+
class="h-8 w-8 rounded-full object-cover border border-neutral-200"
|
|
177
|
+
/>
|
|
178
|
+
<div
|
|
179
|
+
v-else
|
|
180
|
+
class="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center text-xs font-medium text-neutral-500"
|
|
181
|
+
>
|
|
182
|
+
{{ row.original.name.charAt(0).toUpperCase() }}
|
|
183
|
+
</div>
|
|
184
|
+
</template>
|
|
185
|
+
|
|
186
|
+
<template #actions-cell="{ row }">
|
|
187
|
+
<SMoreActions :actions="getRowActions(row.original)" />
|
|
188
|
+
</template>
|
|
189
|
+
</UTable>
|
|
190
|
+
</UCard>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<BrandDrawer v-model="drawerOpen" :brand-id="editingBrandId" @saved="handleDrawerSaved" />
|
|
194
|
+
</SNavigationPage>
|
|
195
|
+
</template>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { MediaFromDb } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import { useMediaQuery, useUploadToSmartchatMutation, useDeleteMediaMutation } from '~/queries'
|
|
4
|
+
import { formatDate } from '@dev.smartpricing/message-composer-utils/utils'
|
|
5
|
+
|
|
6
|
+
const { t } = useI18n()
|
|
7
|
+
|
|
8
|
+
const { data: media, isPending, refetch } = useMediaQuery()
|
|
9
|
+
const { mutateAsync: uploadToSmartchat } = useUploadToSmartchatMutation()
|
|
10
|
+
const { mutateAsync: deleteMedia } = useDeleteMediaMutation()
|
|
11
|
+
|
|
12
|
+
const uploadingIds = ref<Set<string>>(new Set())
|
|
13
|
+
const deletingIds = ref<Set<string>>(new Set())
|
|
14
|
+
|
|
15
|
+
async function handleUploadToSmartchat(item: MediaFromDb) {
|
|
16
|
+
uploadingIds.value.add(item.id)
|
|
17
|
+
try {
|
|
18
|
+
await uploadToSmartchat(item.id)
|
|
19
|
+
useToast().add({
|
|
20
|
+
color: 'success',
|
|
21
|
+
title: t('common.success'),
|
|
22
|
+
description: t('pages.images.image_synced'),
|
|
23
|
+
})
|
|
24
|
+
refetch()
|
|
25
|
+
} finally {
|
|
26
|
+
uploadingIds.value.delete(item.id)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function handleDelete(item: MediaFromDb) {
|
|
31
|
+
if (!confirm(t('pages.images.delete_confirm', { name: item.file_name }))) return
|
|
32
|
+
|
|
33
|
+
deletingIds.value.add(item.id)
|
|
34
|
+
try {
|
|
35
|
+
await deleteMedia(item.id)
|
|
36
|
+
useToast().add({
|
|
37
|
+
color: 'success',
|
|
38
|
+
title: t('common.success'),
|
|
39
|
+
description: t('pages.images.image_deleted'),
|
|
40
|
+
})
|
|
41
|
+
refetch()
|
|
42
|
+
} finally {
|
|
43
|
+
deletingIds.value.delete(item.id)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<SNavigationPage>
|
|
50
|
+
<template #header>
|
|
51
|
+
<SNavigationBarHeader
|
|
52
|
+
:title="t('pages.images.title')"
|
|
53
|
+
:data-testid="imagesPageTestIds.navbar"
|
|
54
|
+
/>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<div v-if="isPending" class="flex justify-center py-12">
|
|
58
|
+
<UIcon
|
|
59
|
+
name="i-heroicons-arrow-path"
|
|
60
|
+
class="animate-spin h-8 w-8"
|
|
61
|
+
:data-testid="imagesPageTestIds.loadingIcon"
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div
|
|
66
|
+
v-else-if="!media || media.length === 0"
|
|
67
|
+
class="text-center py-12 text-gray-500"
|
|
68
|
+
:data-testid="imagesPageTestIds.emptyState"
|
|
69
|
+
>
|
|
70
|
+
{{ t('pages.images.no_images_uploaded') }}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div
|
|
74
|
+
v-else
|
|
75
|
+
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
|
76
|
+
:data-testid="imagesPageTestIds.grid"
|
|
77
|
+
>
|
|
78
|
+
<UCard
|
|
79
|
+
v-for="item in media"
|
|
80
|
+
:key="item.id"
|
|
81
|
+
:data-testid="`${imagesPageTestIds.card}-${item.id}`"
|
|
82
|
+
>
|
|
83
|
+
<div class="space-y-3">
|
|
84
|
+
<img
|
|
85
|
+
:src="item.remote_url"
|
|
86
|
+
:alt="item.file_name"
|
|
87
|
+
class="h-40 w-full object-cover rounded"
|
|
88
|
+
:data-testid="`${imagesPageTestIds.image}-${item.id}`"
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
<div class="space-y-2">
|
|
92
|
+
<div class="flex items-center justify-between">
|
|
93
|
+
<span
|
|
94
|
+
class="text-sm font-medium truncate"
|
|
95
|
+
:title="item.file_name"
|
|
96
|
+
:data-testid="`${imagesPageTestIds.fileName}-${item.id}`"
|
|
97
|
+
>
|
|
98
|
+
{{ item.file_name }}
|
|
99
|
+
</span>
|
|
100
|
+
<UBadge
|
|
101
|
+
:color="item.smartchat_media_id ? 'success' : 'neutral'"
|
|
102
|
+
variant="subtle"
|
|
103
|
+
size="xs"
|
|
104
|
+
:data-testid="`${imagesPageTestIds.syncBadge}-${item.id}`"
|
|
105
|
+
>
|
|
106
|
+
{{
|
|
107
|
+
item.smartchat_media_id ? t('pages.images.synced') : t('pages.images.not_synced')
|
|
108
|
+
}}
|
|
109
|
+
</UBadge>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div class="text-xs text-gray-500 space-y-1">
|
|
113
|
+
<div>{{ t('pages.images.type_label') }} {{ item.file_type }}</div>
|
|
114
|
+
<div>{{ t('pages.images.created_label') }} {{ formatDate(item.created_at) }}</div>
|
|
115
|
+
<div
|
|
116
|
+
v-if="item.smartchat_media_id"
|
|
117
|
+
class="font-mono truncate"
|
|
118
|
+
:title="item.smartchat_media_id"
|
|
119
|
+
>
|
|
120
|
+
{{ t('pages.images.media_id_label') }} {{ item.smartchat_media_id }}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div v-if="!item.smartchat_media_id" class="flex gap-2 pt-2">
|
|
125
|
+
<UButton
|
|
126
|
+
size="xs"
|
|
127
|
+
color="primary"
|
|
128
|
+
block
|
|
129
|
+
:loading="uploadingIds.has(item.id)"
|
|
130
|
+
:data-testid="`${imagesPageTestIds.syncButton}-${item.id}`"
|
|
131
|
+
@click="handleUploadToSmartchat(item)"
|
|
132
|
+
>
|
|
133
|
+
{{ t('pages.images.sync_to_smartchat') }}
|
|
134
|
+
</UButton>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</UCard>
|
|
139
|
+
</div>
|
|
140
|
+
</SNavigationPage>
|
|
141
|
+
</template>
|