@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,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>