@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,112 @@
1
+ import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
2
+ import type {
3
+ MessageGroup,
4
+ MessageGroupWithMessageSummary,
5
+ PostEntity,
6
+ RenderType,
7
+ } from '@dev.smartpricing/message-composer-utils/types'
8
+ import { messageGroupsApi } from '~/api'
9
+
10
+ // QUERIES
11
+ export function useMessageGroupsQuery(
12
+ options?: MaybeRefOrGetter<{
13
+ renderType?: RenderType
14
+ tags?: string[]
15
+ tagsMode?: 'and' | 'or'
16
+ }>,
17
+ _queryOptions?: { polling?: boolean },
18
+ ) {
19
+ const query = useQuery({
20
+ key: () => ['message-groups', JSON.stringify(toValue(options))],
21
+ query: () => messageGroupsApi.getAll(toValue(options)),
22
+ staleTime: 0,
23
+ })
24
+
25
+ // TODO: Implement polling when Pinia Colada supports it
26
+ // For now, polling can be done manually in the component using setInterval + refetch
27
+
28
+ return query
29
+ }
30
+
31
+ export function useMessageGroupQuery(id: MaybeRefOrGetter<string>) {
32
+ return useQuery({
33
+ key: () => ['message-groups', toValue(id)],
34
+ query: () => messageGroupsApi.getById(toValue(id)),
35
+ staleTime: 2 * 60 * 1000,
36
+ })
37
+ }
38
+
39
+ // MUTATIONS
40
+ export function useCreateMessageGroupMutation() {
41
+ const queryCache = useQueryCache()
42
+
43
+ return useMutation({
44
+ mutation: messageGroupsApi.create,
45
+ onSuccess: () => {
46
+ queryCache.invalidateQueries({ key: ['message-groups'] })
47
+ },
48
+ })
49
+ }
50
+
51
+ export function useUpdateMessageGroupMutation() {
52
+ const queryCache = useQueryCache()
53
+
54
+ return useMutation({
55
+ mutation: ({ id, data }: { id: string; data: Partial<PostEntity<MessageGroup>> }) =>
56
+ messageGroupsApi.update(id, data),
57
+ onSuccess: (_, { id }) => {
58
+ queryCache.invalidateQueries({ key: ['message-groups', id] })
59
+ },
60
+ })
61
+ }
62
+
63
+ export function usePublishMessageGroupMutation() {
64
+ const queryCache = useQueryCache()
65
+
66
+ return useMutation({
67
+ mutation: messageGroupsApi.publish,
68
+ onSuccess: () => {
69
+ queryCache.invalidateQueries({ key: ['message-groups'] })
70
+ },
71
+ })
72
+ }
73
+
74
+ export function useUnpublishMessageGroupMutation() {
75
+ const queryCache = useQueryCache()
76
+
77
+ return useMutation({
78
+ mutation: messageGroupsApi.unpublish,
79
+ onSuccess: () => {
80
+ queryCache.invalidateQueries({ key: ['message-groups'] })
81
+ },
82
+ })
83
+ }
84
+
85
+ export function useDeleteMessageGroupMutation() {
86
+ const queryCache = useQueryCache()
87
+
88
+ return useMutation({
89
+ mutation: messageGroupsApi.delete,
90
+ onMutate: async (id) => {
91
+ // Optimistic update
92
+ queryCache.cancelQueries({ key: ['message-groups'] })
93
+ const previous = queryCache.getQueryData(['message-groups'])
94
+
95
+ queryCache.setQueryData(
96
+ ['message-groups'],
97
+ (old: MessageGroupWithMessageSummary[] | undefined) => old?.filter((g) => g.id !== id),
98
+ )
99
+
100
+ return { previous }
101
+ },
102
+ onError: (_, __, context) => {
103
+ // Rollback on error
104
+ if (context?.previous) {
105
+ queryCache.setQueryData(['message-groups'], context.previous)
106
+ }
107
+ },
108
+ onSuccess: () => {
109
+ queryCache.invalidateQueries({ key: ['message-groups'] })
110
+ },
111
+ })
112
+ }
@@ -0,0 +1,42 @@
1
+ import { useMutation, useQueryCache } from '@pinia/colada'
2
+ import type { Message, PostEntity } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { messagesApi } from '~/api'
4
+
5
+ export function useCreateMessageMutation() {
6
+ const queryCache = useQueryCache()
7
+
8
+ return useMutation({
9
+ mutation: messagesApi.create,
10
+ onSuccess: (data) => {
11
+ queryCache.invalidateQueries({
12
+ key: ['message-groups', data.message_group_id],
13
+ })
14
+ },
15
+ })
16
+ }
17
+
18
+ export function useUpdateMessageMutation() {
19
+ const queryCache = useQueryCache()
20
+
21
+ return useMutation({
22
+ mutation: ({ id, data }: { id: string; data: Partial<PostEntity<Message>> }) =>
23
+ messagesApi.update(id, data),
24
+ onSuccess: (data) => {
25
+ queryCache.invalidateQueries({
26
+ key: ['message-groups', data.message_group_id],
27
+ })
28
+ },
29
+ })
30
+ }
31
+
32
+ export function useDeleteMessageMutation() {
33
+ const queryCache = useQueryCache()
34
+
35
+ return useMutation({
36
+ mutation: ({ id, messageGroupId }: { id: string; messageGroupId: string }) =>
37
+ messagesApi.delete(id).then(() => messageGroupId),
38
+ onSuccess: (messageGroupId) => {
39
+ queryCache.invalidateQueries({ key: ['message-groups', messageGroupId] })
40
+ },
41
+ })
42
+ }
@@ -0,0 +1,13 @@
1
+ import { useMutation, useQueryCache } from '@pinia/colada'
2
+ import { metaApi } from '~/api'
3
+
4
+ export function useSubmitToMetaMutation() {
5
+ const queryCache = useQueryCache()
6
+
7
+ return useMutation({
8
+ mutation: metaApi.submitGroup,
9
+ onSuccess: (_, messageGroupId) => {
10
+ queryCache.invalidateQueries({ key: ['message-groups', messageGroupId] })
11
+ },
12
+ })
13
+ }
@@ -0,0 +1,50 @@
1
+ import { jwtDecode } from 'jwt-decode'
2
+
3
+ interface TokenData {
4
+ id: number
5
+ role: string
6
+ accounting: boolean
7
+ email: string
8
+ }
9
+
10
+ export const useAdminStore = defineStore('admin', () => {
11
+ const appContext = useAppContextStore()
12
+ const adminModeActive = ref(false)
13
+
14
+ const isAccounting = computed(() => {
15
+ const token = appContext.authenticationToken
16
+ if (!token) return false
17
+ try {
18
+ const payload = jwtDecode<TokenData>(token)
19
+ return payload.accounting === true
20
+ } catch {
21
+ return false
22
+ }
23
+ })
24
+
25
+ function activateAdminMode() {
26
+ if (!isAccounting.value) return
27
+ adminModeActive.value = true
28
+ }
29
+
30
+ function deactivateAdminMode() {
31
+ adminModeActive.value = false
32
+ }
33
+
34
+ function toggleAdminMode() {
35
+ if (!isAccounting.value) {
36
+ adminModeActive.value = false
37
+ return
38
+ }
39
+ adminModeActive.value = !adminModeActive.value
40
+ }
41
+
42
+ const isAdminModeActive = computed(() => adminModeActive.value)
43
+
44
+ return {
45
+ activateAdminMode,
46
+ deactivateAdminMode,
47
+ isAdminModeActive,
48
+ toggleAdminMode,
49
+ }
50
+ })
@@ -0,0 +1,99 @@
1
+ import { ref, computed, watch } from 'vue'
2
+ import type {
3
+ AppContextConfig,
4
+ AppContextState,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+
7
+ export const useAppContextStore = defineStore('appContext', () => {
8
+ const ctx = useComposerContext()
9
+
10
+ const isInitialized = ref<boolean>(false)
11
+
12
+ // Proxy from injection with defaults (same interface as before)
13
+ const authenticationToken = computed(() => ctx.authenticationToken.value)
14
+ const owner_id = computed(() => ctx.owner_id?.value ?? '')
15
+ const product = computed<NonNullable<AppContextConfig['product']>>(
16
+ () => ctx.product?.value ?? 'browser',
17
+ )
18
+ const strategy = computed<NonNullable<AppContextConfig['strategy']>>(
19
+ () => ctx.strategy?.value ?? 'native',
20
+ )
21
+ const channels = computed<NonNullable<AppContextConfig['channels']>>(
22
+ () => ctx.channels?.value ?? ['email', 'whatsapp'],
23
+ )
24
+ const languages = computed<string[]>(() => ctx.languages?.value ?? ['en', 'de', 'fr', 'it', 'es'])
25
+ const mainLanguage = computed(() => ctx.mainLanguage?.value ?? 'en')
26
+ const dinamicValues = computed<Array<{ value: string; label: string }>>(
27
+ () => ctx.dinamicValues?.value ?? [],
28
+ )
29
+ const default_tags = computed<string[]>(() => ctx.default_tags?.value ?? [])
30
+ const metaWabaId = computed(() => ctx.metaWabaId?.value ?? '')
31
+ const metaAccessToken = computed(() => ctx.metaAccessToken?.value ?? '')
32
+
33
+ // Sync i18n locale when mainLanguage changes
34
+ watch(
35
+ mainLanguage,
36
+ (value) => {
37
+ if (isLocaleManaged(value)) {
38
+ useNuxtApp().$i18n.setLocale(value)
39
+ }
40
+ },
41
+ { immediate: true },
42
+ )
43
+
44
+ // Computed context object for convenience
45
+ const context = computed<AppContextState>(() => ({
46
+ authenticationToken: authenticationToken.value,
47
+ product: product.value,
48
+ strategy: strategy.value,
49
+ channels: channels.value,
50
+ languages: languages.value,
51
+ mainLanguage: mainLanguage.value,
52
+ metaWabaId: metaWabaId.value,
53
+ metaAccessToken: metaAccessToken.value,
54
+ owner_id: owner_id.value,
55
+ default_tags: default_tags.value,
56
+ initializationSource: 'injection',
57
+ }))
58
+
59
+ // Validation
60
+ const validationErrors = computed<Record<string, string>>(() => {
61
+ const errors: Record<string, string> = {}
62
+
63
+ if (!authenticationToken.value) {
64
+ errors.authenticationToken = 'Authentication token is required'
65
+ }
66
+
67
+ if (channels.value?.includes('whatsapp') && !metaWabaId.value) {
68
+ errors.metaConfig = 'Meta WABA ID is required for WhatsApp'
69
+ }
70
+
71
+ return errors
72
+ })
73
+
74
+ // Initialize — parent is responsible for providing valid context
75
+ async function initialize() {
76
+ isInitialized.value = true
77
+ }
78
+
79
+ return {
80
+ authenticationToken,
81
+ product,
82
+ strategy,
83
+ channels,
84
+ languages,
85
+ mainLanguage,
86
+ dinamicValues,
87
+ metaWabaId,
88
+ metaAccessToken,
89
+ owner_id,
90
+ default_tags,
91
+
92
+ isInitialized: computed(() => isInitialized.value),
93
+
94
+ context,
95
+ validationErrors,
96
+
97
+ initialize,
98
+ }
99
+ })
@@ -0,0 +1,220 @@
1
+ import { ref, computed } from 'vue'
2
+ import {
3
+ type EmailBlock,
4
+ type EmailGlobalSettings,
5
+ type PostEntity,
6
+ type EmailMessage,
7
+ type BrandFromDb,
8
+ } from '@dev.smartpricing/message-composer-utils/types'
9
+ import { resolveEmailSettings } from '@dev.smartpricing/message-composer-utils/utils'
10
+ import { applySocialDefaults } from '~/utils/socialDefaults'
11
+ import { brandsApi } from '~/api'
12
+
13
+ export const useEmailEditorStore = defineStore('emailEditor', () => {
14
+ // State
15
+ const blocks = ref<EmailBlock[]>([])
16
+ const selectedBlockId = ref<string | null>(null)
17
+ const globalSettings = ref<EmailGlobalSettings>({})
18
+ const brands = ref<BrandFromDb[]>([])
19
+ const brand = ref<BrandFromDb | null>(null)
20
+
21
+ // Brand computed helpers
22
+ const brandId = computed(() => brand.value?.id ?? null)
23
+ const brandColors = computed(() => brand.value?.colors ?? [])
24
+
25
+ // Resolved settings: globalSettings → brand defaults → hardcoded defaults
26
+ const resolvedSettings = computed(() => resolveEmailSettings(globalSettings.value, brand.value))
27
+
28
+ // Helper to find block recursively
29
+ function findBlockRecursively(
30
+ blockId: string,
31
+ searchBlocks: EmailBlock[],
32
+ ): EmailBlock | undefined {
33
+ for (const block of searchBlocks) {
34
+ if (block.id === blockId) return block
35
+
36
+ // Search in grid columns
37
+ if (block.type === 'grid' && block.settings.columnData) {
38
+ for (const column of block.settings.columnData) {
39
+ const found = findBlockRecursively(blockId, column.blocks)
40
+ if (found) return found
41
+ }
42
+ }
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ // Computed
48
+ const selectedBlock = computed(() => {
49
+ if (!selectedBlockId.value) return undefined
50
+ return findBlockRecursively(selectedBlockId.value, blocks.value)
51
+ })
52
+
53
+ const hasBlocks = computed(() => blocks.value.length > 0)
54
+
55
+ // Actions - Block CRUD
56
+ function addBlock(block: EmailBlock) {
57
+ const newBlock = {
58
+ ...structuredClone(toRaw(block)),
59
+ id: crypto.randomUUID(),
60
+ }
61
+ // Auto-enable socials that have brand URLs
62
+ if (newBlock.type === 'socials' && brand.value?.default_socials) {
63
+ newBlock.settings.socials = applySocialDefaults(brand.value.default_socials)
64
+ }
65
+ blocks.value.push(newBlock)
66
+ }
67
+
68
+ function insertBlock(block: EmailBlock, index: number) {
69
+ blocks.value.splice(index, 0, block)
70
+ }
71
+
72
+ function removeBlock(blockId: string) {
73
+ const index = blocks.value.findIndex((b) => b.id === blockId)
74
+ if (index > -1) {
75
+ blocks.value.splice(index, 1)
76
+ // Deselect if removing selected block
77
+ if (selectedBlockId.value === blockId) {
78
+ selectedBlockId.value = null
79
+ }
80
+ // Reorder remaining blocks
81
+ }
82
+ }
83
+
84
+ function updateBlock(blockId: string, updates: Partial<EmailBlock>) {
85
+ const block = findBlockRecursively(blockId, blocks.value)
86
+ if (block) {
87
+ Object.assign(block, updates)
88
+ }
89
+ }
90
+
91
+ function duplicateBlock(blockId: string) {
92
+ const index = blocks.value.findIndex((b) => b.id === blockId)
93
+ const block = blocks.value[index]
94
+ if (block) {
95
+ const newBlock = {
96
+ ...structuredClone(toRaw(block)),
97
+ id: crypto.randomUUID(),
98
+ }
99
+ insertBlock(newBlock, index + 1)
100
+ return newBlock.id
101
+ }
102
+ return null
103
+ }
104
+
105
+ // Actions - Selection
106
+ function selectBlock(blockId: string | null) {
107
+ selectedBlockId.value = blockId
108
+ }
109
+
110
+ function clearSelection() {
111
+ selectedBlockId.value = null
112
+ }
113
+
114
+ // Actions - Global Settings
115
+ function updateGlobalSettings(updates: Partial<EmailGlobalSettings>) {
116
+ Object.assign(globalSettings.value, updates)
117
+ }
118
+
119
+ // Actions - Bulk Operations
120
+ function setBlocks(newBlocks: EmailBlock[]) {
121
+ blocks.value = newBlocks
122
+ }
123
+
124
+ function clearBlocks() {
125
+ blocks.value = []
126
+ selectedBlockId.value = null
127
+ }
128
+
129
+ function resetGlobalSettings() {
130
+ globalSettings.value = {}
131
+ }
132
+
133
+ function resetEditor() {
134
+ blocks.value = []
135
+ selectedBlockId.value = null
136
+ brand.value = null
137
+ resetGlobalSettings()
138
+ }
139
+
140
+ function setBrand(newBrand: BrandFromDb | null) {
141
+ brand.value = newBrand
142
+ }
143
+
144
+ async function fetchBrands(options?: {
145
+ sort_by?: 'created_at' | 'name'
146
+ direction?: 'asc' | 'desc'
147
+ }) {
148
+ brands.value = await brandsApi.getAll(options)
149
+ return brands.value
150
+ }
151
+
152
+ /** Set brand for new template (oldest) or existing template (by id, or null) */
153
+ async function initBrand(brandIdToRestore?: string | null) {
154
+ const list = await fetchBrands({ sort_by: 'created_at', direction: 'asc' })
155
+ if (brandIdToRestore) {
156
+ brand.value = list.find((b) => b.id === brandIdToRestore) ?? null
157
+ } else if (brandIdToRestore === undefined && list.length) {
158
+ // Create mode: default to oldest brand
159
+ brand.value = list[0]
160
+ }
161
+ // brandIdToRestore === null → keep null (explicit no-brand)
162
+ }
163
+
164
+ // Data Loading/Export
165
+ function loadFromData(data: PostEntity<EmailMessage>) {
166
+ blocks.value = data.body.blocks || []
167
+ globalSettings.value = data.body.settings || {}
168
+ selectedBlockId.value = null
169
+ }
170
+
171
+ function toJSON(): { blocks: EmailBlock[]; settings: EmailGlobalSettings } {
172
+ return {
173
+ blocks: blocks.value,
174
+ settings: globalSettings.value,
175
+ }
176
+ }
177
+
178
+ return {
179
+ // State
180
+ blocks,
181
+ selectedBlockId,
182
+ globalSettings,
183
+ brands,
184
+ brand,
185
+ brandId,
186
+ brandColors,
187
+ resolvedSettings,
188
+
189
+ // Computed
190
+ selectedBlock,
191
+ hasBlocks,
192
+
193
+ // Actions - CRUD
194
+ addBlock,
195
+ insertBlock,
196
+ removeBlock,
197
+ updateBlock,
198
+ duplicateBlock,
199
+
200
+ // Actions - Selection
201
+ selectBlock,
202
+ clearSelection,
203
+
204
+ // Actions - Settings
205
+ updateGlobalSettings,
206
+ resetGlobalSettings,
207
+ setBrand,
208
+ fetchBrands,
209
+ initBrand,
210
+
211
+ // Actions - Bulk
212
+ setBlocks,
213
+ clearBlocks,
214
+ resetEditor,
215
+
216
+ // Data Import/Export
217
+ loadFromData,
218
+ toJSON,
219
+ }
220
+ })
@@ -0,0 +1,3 @@
1
+ export const useMainStore = defineStore('main', () => {
2
+ return {}
3
+ })
@@ -0,0 +1,116 @@
1
+ import type { SubmissionJob } from '@dev.smartpricing/message-composer-utils/types'
2
+ import { metaApi } from '~/api/meta'
3
+ import { useQueryCache } from '@pinia/colada'
4
+ import { useAppContextStore } from './appContext'
5
+
6
+ interface ActiveJob {
7
+ jobId: string
8
+ messageGroupId: string
9
+ startedAt: number
10
+ }
11
+
12
+ export const useMetaSubmissionStore = () => {
13
+ const toast = useToast()
14
+ const queryCache = useQueryCache()
15
+ const appContext = useAppContextStore()
16
+ const { t } = useI18n()
17
+
18
+ const activeJobs = useState<Map<string, ActiveJob>>('meta-submission-jobs', () => new Map())
19
+ const pollingInterval = useState<NodeJS.Timeout | null>('meta-polling-interval', () => null)
20
+
21
+ const isConfigured = computed(() => !!appContext.metaWabaId)
22
+
23
+ const submitToMeta = async (messageGroupId: string) => {
24
+ if (!isConfigured.value) {
25
+ throw new Error('Meta configuration not available')
26
+ }
27
+
28
+ try {
29
+ const response = await metaApi.submitGroup(messageGroupId)
30
+ startBackgroundPolling(response.jobId, messageGroupId)
31
+ return response
32
+ } catch (error) {
33
+ console.error('Failed to submit to Meta:', error)
34
+ throw error
35
+ }
36
+ }
37
+
38
+ const startBackgroundPolling = (jobId: string, messageGroupId: string) => {
39
+ // Add job to active jobs
40
+ activeJobs.value.set(jobId, {
41
+ jobId,
42
+ messageGroupId,
43
+ startedAt: Date.now(),
44
+ })
45
+
46
+ // Start polling if not already running
47
+ if (!pollingInterval.value) {
48
+ pollingInterval.value = setInterval(pollAllJobs, 10000) // 10 seconds
49
+ }
50
+ }
51
+
52
+ const pollAllJobs = async () => {
53
+ if (activeJobs.value.size === 0) {
54
+ stopPolling()
55
+ return
56
+ }
57
+
58
+ const jobsToCheck = Array.from(activeJobs.value.values())
59
+
60
+ for (const job of jobsToCheck) {
61
+ try {
62
+ const status = await metaApi.getJobStatus(job.jobId)
63
+
64
+ if (status.status === 'completed' || status.status === 'failed') {
65
+ handleJobComplete(status, job.messageGroupId)
66
+ activeJobs.value.delete(job.jobId)
67
+ }
68
+ } catch (error) {
69
+ console.error(`Failed to poll job ${job.jobId}:`, error)
70
+ // Remove job after 5 minutes of failed polling
71
+ if (Date.now() - job.startedAt > 5 * 60 * 1000) {
72
+ activeJobs.value.delete(job.jobId)
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ const handleJobComplete = (status: SubmissionJob, messageGroupId: string) => {
79
+ // Invalidate message group cache
80
+ queryCache.invalidateQueries({
81
+ key: ['message-groups', messageGroupId],
82
+ })
83
+
84
+ // Show notification
85
+ if (status.failed_count === 0) {
86
+ toast.add({
87
+ color: 'success',
88
+ title: t('meta_submission.success'),
89
+ })
90
+ } else {
91
+ toast.add({
92
+ color: 'error',
93
+ title: t('meta_submission.error'),
94
+ description: t('meta_submission.failed_count', { count: status.failed_count }),
95
+ })
96
+ }
97
+ }
98
+
99
+ const stopPolling = () => {
100
+ if (pollingInterval.value) {
101
+ clearInterval(pollingInterval.value)
102
+ pollingInterval.value = null
103
+ }
104
+ }
105
+
106
+ const hasActiveJobs = computed(() => activeJobs.value.size > 0)
107
+
108
+ return {
109
+ activeJobs,
110
+ hasActiveJobs,
111
+ isConfigured,
112
+ submitToMeta,
113
+ startBackgroundPolling,
114
+ stopPolling,
115
+ }
116
+ }
@@ -0,0 +1,14 @@
1
+ declare module 'nuxt/schema' {
2
+ interface AppConfigInput {
3
+ composer?: {
4
+ tracking?: boolean
5
+ }
6
+ }
7
+ interface AppConfig {
8
+ composer: {
9
+ tracking: boolean
10
+ }
11
+ }
12
+ }
13
+
14
+ export {}
@@ -0,0 +1,9 @@
1
+ import type { HookResult } from '@nuxt/schema'
2
+
3
+ declare module '#app' {
4
+ interface RuntimeNuxtHooks {
5
+ 'composer:auth-error': (payload: { statusCode: number }) => HookResult
6
+ }
7
+ }
8
+
9
+ export {}