@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,212 @@
1
+ import { ref, computed, watch, toRaw, type MaybeRef } from 'vue'
2
+ import { deepClone, deepEqual } from '@dev.smartpricing/message-composer-utils/utils'
3
+ import type { WhatsappMessage } from '@dev.smartpricing/message-composer-utils/types'
4
+
5
+ /**
6
+ * Storage key pattern for change tracking
7
+ */
8
+ function getStorageKey(messageGroupId: string | number): string {
9
+ return `message-changes-${messageGroupId}`
10
+ }
11
+
12
+ /**
13
+ * Tracked message state stored in localStorage
14
+ */
15
+ interface TrackedMessageState {
16
+ header: WhatsappMessage['body']['header']
17
+ body: WhatsappMessage['body']['body']
18
+ footer: WhatsappMessage['body']['footer']
19
+ buttons: WhatsappMessage['body']['buttons']
20
+ timestamp: number
21
+ }
22
+
23
+ /**
24
+ * Storage structure: keyed by language ID
25
+ */
26
+ type ChangeTrackingStorage = Record<string, TrackedMessageState>
27
+
28
+ /**
29
+ * Composable for tracking changes to messages with localStorage persistence
30
+ *
31
+ * @param messageGroupId - Reactive reference to message group ID
32
+ * @returns API methods for change tracking
33
+ */
34
+ export function useChangeTracking(messageGroupId: MaybeRef<string | number>) {
35
+ const groupId = computed(() =>
36
+ typeof messageGroupId === 'object' && 'value' in messageGroupId
37
+ ? messageGroupId.value
38
+ : messageGroupId,
39
+ )
40
+
41
+ // Internal state: stores original message states per language
42
+ const originalStates = ref<ChangeTrackingStorage>({})
43
+
44
+ /**
45
+ * Load original states from localStorage
46
+ */
47
+ function loadFromStorage(): void {
48
+ try {
49
+ const key = getStorageKey(groupId.value)
50
+ const stored = localStorage.getItem(key)
51
+ if (stored) {
52
+ originalStates.value = JSON.parse(stored)
53
+ } else {
54
+ originalStates.value = {}
55
+ }
56
+ } catch (error) {
57
+ console.error('Failed to load change tracking from storage:', error)
58
+ originalStates.value = {}
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Save original states to localStorage
64
+ */
65
+ function saveToStorage(): void {
66
+ try {
67
+ const key = getStorageKey(groupId.value)
68
+ localStorage.setItem(key, JSON.stringify(originalStates.value))
69
+ } catch (error) {
70
+ console.error('Failed to save change tracking to storage:', error)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Record original state for a message
76
+ * Stores header, body, footer, buttons, and timestamp
77
+ *
78
+ * @param language - Language ID
79
+ * @param message - Message to track
80
+ */
81
+ function recordOriginalState(language: string, message: WhatsappMessage): void {
82
+ // Use toRaw to remove Vue reactivity before cloning
83
+ const rawMessage = toRaw(message)
84
+ originalStates.value[language] = {
85
+ header: deepClone(toRaw(rawMessage.body.header)),
86
+ body: deepClone(toRaw(rawMessage.body.body)),
87
+ footer: deepClone(toRaw(rawMessage.body.footer)),
88
+ buttons: deepClone(toRaw(rawMessage.body.buttons)),
89
+ timestamp: Date.now(),
90
+ }
91
+ saveToStorage()
92
+ }
93
+
94
+ /**
95
+ * Check if a message has any changes compared to original state
96
+ *
97
+ * @param language - Language ID
98
+ * @param currentMessage - Current message to compare
99
+ * @returns true if message has changes, false otherwise
100
+ */
101
+ function hasChanges(language: string, currentMessage: WhatsappMessage): boolean {
102
+ const original = originalStates.value[language]
103
+ if (!original) return false
104
+
105
+ // Use toRaw to remove Vue reactivity before comparison
106
+ const rawMessage = toRaw(currentMessage)
107
+
108
+ // Compare each field using deep equality
109
+ const headerChanged = !deepEqual(original.header, toRaw(rawMessage.body.header))
110
+ const bodyChanged = !deepEqual(original.body, toRaw(rawMessage.body.body))
111
+ const footerChanged = !deepEqual(original.footer, toRaw(rawMessage.body.footer))
112
+ const buttonsChanged = !deepEqual(original.buttons, toRaw(rawMessage.body.buttons))
113
+
114
+ return headerChanged || bodyChanged || footerChanged || buttonsChanged
115
+ }
116
+
117
+ /**
118
+ * Get array of changed field names
119
+ *
120
+ * @param language - Language ID
121
+ * @param currentMessage - Current message to compare
122
+ * @returns Array of field names that have changed
123
+ */
124
+ function getChangedFields(language: string, currentMessage: WhatsappMessage): string[] {
125
+ const original = originalStates.value[language]
126
+ if (!original) return []
127
+
128
+ // Use toRaw to remove Vue reactivity before comparison
129
+ const rawMessage = toRaw(currentMessage)
130
+ const changedFields: string[] = []
131
+
132
+ if (!deepEqual(original.header, toRaw(rawMessage.body.header))) {
133
+ changedFields.push('header')
134
+ }
135
+ if (!deepEqual(original.body, toRaw(rawMessage.body.body))) {
136
+ changedFields.push('body')
137
+ }
138
+ if (!deepEqual(original.footer, toRaw(rawMessage.body.footer))) {
139
+ changedFields.push('footer')
140
+ }
141
+ if (!deepEqual(original.buttons, toRaw(rawMessage.body.buttons))) {
142
+ changedFields.push('buttons')
143
+ }
144
+
145
+ return changedFields
146
+ }
147
+
148
+ /**
149
+ * Get array of language IDs that have changes
150
+ *
151
+ * @param messages - Record of all messages by language ID
152
+ * @returns Array of language IDs with changes
153
+ */
154
+ function getLanguagesWithChanges(messages: Record<string, WhatsappMessage>): string[] {
155
+ return Object.keys(messages).filter(
156
+ (languageId) => messages[languageId] && hasChanges(languageId, messages[languageId]),
157
+ )
158
+ }
159
+
160
+ /**
161
+ * Get count of changed fields for a specific language
162
+ *
163
+ * @param language - Language ID
164
+ * @param currentMessage - Current message to compare
165
+ * @returns Number of changed fields
166
+ */
167
+ function getChangeCount(language: string, currentMessage: WhatsappMessage): number {
168
+ return getChangedFields(language, currentMessage).length
169
+ }
170
+
171
+ /**
172
+ * Clear changes for a specific language
173
+ *
174
+ * @param language - Language ID to clear
175
+ */
176
+ function clearChanges(language: string): void {
177
+ delete originalStates.value[language]
178
+ saveToStorage()
179
+ }
180
+
181
+ /**
182
+ * Clear all changes for this message group
183
+ */
184
+ function clearAllChanges(): void {
185
+ originalStates.value = {}
186
+ try {
187
+ const key = getStorageKey(groupId.value)
188
+ localStorage.removeItem(key)
189
+ } catch (error) {
190
+ console.error('Failed to clear change tracking from storage:', error)
191
+ }
192
+ }
193
+
194
+ // Watch for messageGroupId changes and reload from storage
195
+ watch(
196
+ groupId,
197
+ () => {
198
+ loadFromStorage()
199
+ },
200
+ { immediate: true },
201
+ )
202
+
203
+ return {
204
+ recordOriginalState,
205
+ hasChanges,
206
+ getChangedFields,
207
+ getLanguagesWithChanges,
208
+ getChangeCount,
209
+ clearChanges,
210
+ clearAllChanges,
211
+ }
212
+ }
@@ -0,0 +1,227 @@
1
+ import { ref, computed, watch, toRaw, type MaybeRef } from 'vue'
2
+ import { deepClone, deepEqual } from '@dev.smartpricing/message-composer-utils/utils'
3
+ import type { EmailMessage, EmailBlock } from '@dev.smartpricing/message-composer-utils/types'
4
+
5
+ /**
6
+ * Storage key pattern for email change tracking
7
+ */
8
+ function getStorageKey(messageGroupId: string | number): string {
9
+ return `email-message-changes-${messageGroupId}`
10
+ }
11
+
12
+ /**
13
+ * Tracked email message state stored in localStorage
14
+ */
15
+ interface TrackedEmailState {
16
+ subject: string
17
+ blocks: EmailBlock[]
18
+ timestamp: number
19
+ }
20
+
21
+ /**
22
+ * Storage structure: keyed by language ID
23
+ */
24
+ type EmailChangeTrackingStorage = Record<string, TrackedEmailState>
25
+
26
+ /**
27
+ * Composable for tracking changes to email messages with localStorage persistence
28
+ *
29
+ * @param messageGroupId - Reactive reference to message group ID
30
+ * @returns API methods for change tracking
31
+ */
32
+ export function useChangeTrackingEmail(messageGroupId: MaybeRef<string | number>) {
33
+ const groupId = computed(() =>
34
+ typeof messageGroupId === 'object' && 'value' in messageGroupId
35
+ ? messageGroupId.value
36
+ : messageGroupId,
37
+ )
38
+
39
+ // Internal state: stores original message states per language
40
+ const originalStates = ref<EmailChangeTrackingStorage>({})
41
+
42
+ /**
43
+ * Load original states from localStorage
44
+ */
45
+ function loadFromStorage(): void {
46
+ try {
47
+ const key = getStorageKey(groupId.value)
48
+ const stored = localStorage.getItem(key)
49
+ if (stored) {
50
+ originalStates.value = JSON.parse(stored)
51
+ } else {
52
+ originalStates.value = {}
53
+ }
54
+ } catch (error) {
55
+ console.error('Failed to load email change tracking from storage:', error)
56
+ originalStates.value = {}
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Save original states to localStorage
62
+ */
63
+ function saveToStorage(): void {
64
+ try {
65
+ const key = getStorageKey(groupId.value)
66
+ localStorage.setItem(key, JSON.stringify(originalStates.value))
67
+ } catch (error) {
68
+ console.error('Failed to save email change tracking to storage:', error)
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Record original state for an email message
74
+ * Stores subject, blocks, and timestamp
75
+ *
76
+ * @param language - Language ID
77
+ * @param message - Email message to track
78
+ */
79
+ function recordOriginalState(language: string, message: EmailMessage): void {
80
+ // Use toRaw to remove Vue reactivity before cloning
81
+ const rawMessage = toRaw(message)
82
+ originalStates.value[language] = {
83
+ subject: rawMessage.body.subject,
84
+ blocks: deepClone(toRaw(rawMessage.body.blocks)),
85
+ timestamp: Date.now(),
86
+ }
87
+ saveToStorage()
88
+ }
89
+
90
+ /**
91
+ * Check if a message has any changes compared to original state
92
+ *
93
+ * @param language - Language ID
94
+ * @param currentMessage - Current email message to compare
95
+ * @returns true if message has changes, false otherwise
96
+ */
97
+ function hasChanges(language: string, currentMessage: EmailMessage): boolean {
98
+ const original = originalStates.value[language]
99
+ if (!original) return false
100
+
101
+ // Use toRaw to remove Vue reactivity before comparison
102
+ const rawMessage = toRaw(currentMessage)
103
+
104
+ // Compare subject
105
+ const subjectChanged = original.subject !== rawMessage.body.subject
106
+
107
+ // Compare blocks using deep equality
108
+ const blocksChanged = !deepEqual(original.blocks, toRaw(rawMessage.body.blocks))
109
+
110
+ return subjectChanged || blocksChanged
111
+ }
112
+
113
+ /**
114
+ * Get array of changed block IDs
115
+ *
116
+ * @param language - Language ID
117
+ * @param currentMessage - Current email message to compare
118
+ * @returns Array of block IDs that have changed
119
+ */
120
+ function getChangedBlockIds(language: string, currentMessage: EmailMessage): string[] {
121
+ const original = originalStates.value[language]
122
+ if (!original) return []
123
+
124
+ // Use toRaw to remove Vue reactivity before comparison
125
+ const rawMessage = toRaw(currentMessage)
126
+ const changedBlockIds: string[] = []
127
+
128
+ // Compare blocks by ID
129
+ const originalBlocksMap = new Map(original.blocks.map((b) => [b.id, b]))
130
+ const currentBlocksMap = new Map(rawMessage.body.blocks.map((b) => [b.id, toRaw(b)]))
131
+
132
+ // Check for changes
133
+ for (const [id, currentBlock] of currentBlocksMap) {
134
+ const originalBlock = originalBlocksMap.get(id)
135
+ if (!originalBlock || !deepEqual(originalBlock, currentBlock)) {
136
+ changedBlockIds.push(id)
137
+ }
138
+ }
139
+
140
+ // Note: Nested grid blocks are part of their parent grid block change detection
141
+
142
+ return changedBlockIds
143
+ }
144
+
145
+ /**
146
+ * Check if subject has changed
147
+ *
148
+ * @param language - Language ID
149
+ * @param currentMessage - Current email message to compare
150
+ * @returns true if subject changed
151
+ */
152
+ function hasSubjectChanged(language: string, currentMessage: EmailMessage): boolean {
153
+ const original = originalStates.value[language]
154
+ if (!original) return false
155
+ return original.subject !== currentMessage.body.subject
156
+ }
157
+
158
+ /**
159
+ * Get array of language IDs that have changes
160
+ *
161
+ * @param messages - Record of all email messages by language ID
162
+ * @returns Array of language IDs with changes
163
+ */
164
+ function getLanguagesWithChanges(messages: Record<string, EmailMessage>): string[] {
165
+ return Object.keys(messages).filter(
166
+ (languageId) => messages[languageId] && hasChanges(languageId, messages[languageId]),
167
+ )
168
+ }
169
+
170
+ /**
171
+ * Get count of changed blocks for a specific language
172
+ *
173
+ * @param language - Language ID
174
+ * @param currentMessage - Current email message to compare
175
+ * @returns Number of changed blocks (includes subject if changed)
176
+ */
177
+ function getChangeCount(language: string, currentMessage: EmailMessage): number {
178
+ let count = getChangedBlockIds(language, currentMessage).length
179
+ if (hasSubjectChanged(language, currentMessage)) {
180
+ count++
181
+ }
182
+ return count
183
+ }
184
+
185
+ /**
186
+ * Clear changes for a specific language
187
+ *
188
+ * @param language - Language ID to clear
189
+ */
190
+ function clearChanges(language: string): void {
191
+ delete originalStates.value[language]
192
+ saveToStorage()
193
+ }
194
+
195
+ /**
196
+ * Clear all changes for this message group
197
+ */
198
+ function clearAllChanges(): void {
199
+ originalStates.value = {}
200
+ try {
201
+ const key = getStorageKey(groupId.value)
202
+ localStorage.removeItem(key)
203
+ } catch (error) {
204
+ console.error('Failed to clear email change tracking from storage:', error)
205
+ }
206
+ }
207
+
208
+ // Watch for messageGroupId changes and reload from storage
209
+ watch(
210
+ groupId,
211
+ () => {
212
+ loadFromStorage()
213
+ },
214
+ { immediate: true },
215
+ )
216
+
217
+ return {
218
+ recordOriginalState,
219
+ hasChanges,
220
+ getChangedBlockIds,
221
+ hasSubjectChanged,
222
+ getLanguagesWithChanges,
223
+ getChangeCount,
224
+ clearChanges,
225
+ clearAllChanges,
226
+ }
227
+ }
@@ -0,0 +1,36 @@
1
+ import { computed } from 'vue'
2
+ import type { RenderType } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { useAppContextStore } from '@/stores/appContext'
4
+
5
+ export function useChannels() {
6
+ const appContext = useAppContextStore()
7
+
8
+ const hasWhatsapp = computed(() => appContext.channels.includes('whatsapp'))
9
+ const hasEmail = computed(() => appContext.channels.includes('email'))
10
+
11
+ const allowedRenderTypes = computed<RenderType[]>(() => {
12
+ const types: RenderType[] = []
13
+ if (hasWhatsapp.value) types.push('whatsapp_v1')
14
+ if (hasEmail.value) types.push('email_v1')
15
+ return types
16
+ })
17
+
18
+ const defaultRenderType = computed<RenderType>(() => allowedRenderTypes.value[0] || 'whatsapp_v1')
19
+
20
+ function isChannelAllowed(channel: 'whatsapp' | 'email'): boolean {
21
+ return appContext.channels.includes(channel)
22
+ }
23
+
24
+ function isRenderTypeAllowed(renderType: RenderType): boolean {
25
+ return allowedRenderTypes.value.includes(renderType)
26
+ }
27
+
28
+ return {
29
+ hasWhatsapp,
30
+ hasEmail,
31
+ allowedRenderTypes,
32
+ defaultRenderType,
33
+ isChannelAllowed,
34
+ isRenderTypeAllowed,
35
+ }
36
+ }
@@ -0,0 +1,43 @@
1
+ import type { EmailBlock } from '@dev.smartpricing/message-composer-utils/types'
2
+ import type { JSONContent } from '@tiptap/vue-3'
3
+
4
+ function extractTextFromJSON(node: JSONContent | undefined): string {
5
+ if (!node) return ''
6
+ let text = ''
7
+ if (node.text) text += node.text
8
+ if (node.content) {
9
+ for (const child of node.content) {
10
+ text += extractTextFromJSON(child)
11
+ }
12
+ }
13
+ return text
14
+ }
15
+
16
+ function getBlockText(block: EmailBlock): string {
17
+ if (block.type === 'heading' || block.type === 'paragraph') {
18
+ const content = (block.settings as { content?: JSONContent }).content
19
+ return extractTextFromJSON(content)
20
+ }
21
+ if (block.type === 'button') {
22
+ return (block.settings as { text?: string }).text || ''
23
+ }
24
+ return ''
25
+ }
26
+
27
+ export function useEmailBlockContext(currentBlockId: string) {
28
+ const emailEditorStore = useEmailEditorStore()
29
+
30
+ const context = computed(() => {
31
+ const parts: string[] = []
32
+ for (const block of emailEditorStore.blocks) {
33
+ if (block.id === currentBlockId) continue
34
+ const text = getBlockText(block).trim()
35
+ if (text) {
36
+ parts.push(`[${block.type}]: ${text}`)
37
+ }
38
+ }
39
+ return parts.join('\n') || undefined
40
+ })
41
+
42
+ return { context }
43
+ }
@@ -0,0 +1,39 @@
1
+ import { ref, unref, type ComputedRef } from 'vue'
2
+ import { type RouteLocationRaw } from 'vue-router'
3
+
4
+ export function useExitConfirmation(shouldBlockNavigation: ComputedRef<boolean>) {
5
+ const isForcingNavigation = ref(false)
6
+ const isNavigatingTo = ref<RouteLocationRaw | null>(null)
7
+
8
+ const { confirm } = useConfirm()
9
+ const { t } = useI18n()
10
+
11
+ // Setup route guard
12
+ onBeforeRouteLeave((_to, _from, next) => {
13
+ const _isForcedNavigation = unref(isForcingNavigation)
14
+ isForcingNavigation.value = false
15
+
16
+ if (shouldBlockNavigation.value && !_isForcedNavigation) {
17
+ isNavigatingTo.value = _to
18
+ next(false)
19
+
20
+ confirm({
21
+ title: t('dialogs.unsaved_changes.title'),
22
+ message: t('dialogs.unsaved_changes.message'),
23
+ confirmLabel: t('dialogs.unsaved_changes.leave'),
24
+ cancelLabel: t('dialogs.unsaved_changes.cancel'),
25
+ action: () => {
26
+ const target = isNavigatingTo.value
27
+ if (target) {
28
+ isForcingNavigation.value = true
29
+ navigateTo(target)
30
+ isNavigatingTo.value = null
31
+ }
32
+ },
33
+ })
34
+ } else {
35
+ isNavigatingTo.value = null
36
+ next()
37
+ }
38
+ })
39
+ }
@@ -0,0 +1,15 @@
1
+ import type { RouteLocationRaw } from 'vue-router'
2
+
3
+ export function useGoBack() {
4
+ const router = useRouter()
5
+
6
+ function goBackOrFallback(fallback: RouteLocationRaw = '/') {
7
+ if (router.options.history.state.back) {
8
+ router.back()
9
+ } else {
10
+ navigateTo(fallback, { replace: true })
11
+ }
12
+ }
13
+
14
+ return { goBackOrFallback }
15
+ }
@@ -0,0 +1,55 @@
1
+ import { computed, type Ref } from 'vue'
2
+ import type {
3
+ WhatsappMessage,
4
+ LanguageFromDb,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+ import { calculateWhatsappTranslationPercentage } from '@dev.smartpricing/message-composer-utils/utils'
7
+
8
+ interface UseIncompleteTranslationsParams {
9
+ messages: Ref<Record<string, WhatsappMessage>>
10
+ languages: Ref<LanguageFromDb[] | undefined>
11
+ }
12
+
13
+ export function useIncompleteTranslations(params: UseIncompleteTranslationsParams) {
14
+ const { messages, languages } = params
15
+
16
+ const { mainLanguage } = storeToRefs(useAppContextStore())
17
+
18
+ /**
19
+ * Get list of languages with incomplete translations
20
+ */
21
+ const incompleteLanguages = computed(() => {
22
+ if (!languages.value) return []
23
+
24
+ const baseLang = resolveDefaultLanguage(messages.value, mainLanguage.value)
25
+ const baseMessage = baseLang ? messages.value[baseLang] : undefined
26
+ if (!baseMessage) return []
27
+
28
+ const incomplete: Array<{ language: LanguageFromDb; percentage: number }> = []
29
+
30
+ for (const lang of languages.value) {
31
+ const message = messages.value[lang.id]
32
+ if (!message) continue
33
+
34
+ const percentage = calculateWhatsappTranslationPercentage(message, baseMessage)
35
+
36
+ if (percentage < 100) {
37
+ incomplete.push({ language: lang, percentage })
38
+ }
39
+ }
40
+
41
+ return incomplete
42
+ })
43
+
44
+ /**
45
+ * Check if there are any incomplete translations
46
+ */
47
+ const hasIncompleteTranslations = computed(() => {
48
+ return incompleteLanguages.value.length > 0
49
+ })
50
+
51
+ return {
52
+ incompleteLanguages,
53
+ hasIncompleteTranslations,
54
+ }
55
+ }
@@ -0,0 +1,56 @@
1
+ import { computed, type Ref } from 'vue'
2
+ import type {
3
+ EmailMessage,
4
+ LanguageFromDb,
5
+ PostEntity,
6
+ } from '@dev.smartpricing/message-composer-utils/types'
7
+ import { calculateEmailTranslationPercentage } from '@dev.smartpricing/message-composer-utils/utils'
8
+
9
+ interface UseIncompleteTranslationsEmailParams {
10
+ messages: Ref<Record<string, EmailMessage | PostEntity<EmailMessage>>>
11
+ languages: Ref<LanguageFromDb[] | undefined>
12
+ }
13
+
14
+ export function useIncompleteTranslationsEmail(params: UseIncompleteTranslationsEmailParams) {
15
+ const { messages, languages } = params
16
+
17
+ const { mainLanguage } = storeToRefs(useAppContextStore())
18
+
19
+ /**
20
+ * Get list of languages with incomplete translations
21
+ */
22
+ const incompleteLanguages = computed(() => {
23
+ if (!languages.value) return []
24
+
25
+ const baseLang = resolveDefaultLanguage(messages.value, mainLanguage.value)
26
+ const baseMessage = baseLang ? messages.value[baseLang] : undefined
27
+ if (!baseMessage) return []
28
+
29
+ const incomplete: Array<{ language: LanguageFromDb; percentage: number }> = []
30
+
31
+ for (const lang of languages.value) {
32
+ const message = messages.value[lang.id]
33
+ if (!message) continue
34
+
35
+ const percentage = calculateEmailTranslationPercentage(message, baseMessage)
36
+
37
+ if (percentage < 100) {
38
+ incomplete.push({ language: lang, percentage })
39
+ }
40
+ }
41
+
42
+ return incomplete
43
+ })
44
+
45
+ /**
46
+ * Check if there are any incomplete translations
47
+ */
48
+ const hasIncompleteTranslations = computed(() => {
49
+ return incompleteLanguages.value.length > 0
50
+ })
51
+
52
+ return {
53
+ incompleteLanguages,
54
+ hasIncompleteTranslations,
55
+ }
56
+ }