@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,209 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ WA_BUTTON_TEXT_MAX_LENGTH,
4
+ type WaButtonV1,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+ import { useI18n } from 'vue-i18n'
7
+ import PhoneInput from '~/components/Common/PhoneInput.vue'
8
+ import { z } from 'zod'
9
+ import {
10
+ VALID_PHONE_NUMBER_REGEX,
11
+ VALID_URL_REGEX,
12
+ } from '@dev.smartpricing/message-composer-utils/utils'
13
+
14
+ defineOptions({
15
+ name: 'WhatsappTranslatorButtons',
16
+ })
17
+
18
+ const { baseButtons } = defineProps<{
19
+ baseButtons: WaButtonV1[]
20
+ }>()
21
+
22
+ const targetButtons = defineModel<WaButtonV1[]>({
23
+ required: true,
24
+ })
25
+
26
+ const { t } = useI18n()
27
+
28
+ const { copy } = useClipboard()
29
+
30
+ async function copyToClipboard(text: string) {
31
+ try {
32
+ await copy(text)
33
+ } catch (err) {
34
+ console.error('Failed to copy:', err)
35
+ }
36
+ }
37
+
38
+ // Validation schemas
39
+ const buttonTextSchema = z.object({
40
+ text: z.string().max(WA_BUTTON_TEXT_MAX_LENGTH, {
41
+ message: t('editor.validation.error.textTooLong'),
42
+ }),
43
+ })
44
+
45
+ const phoneButtonSchema = z.object({
46
+ phoneNumber: z
47
+ .string()
48
+ .regex(VALID_PHONE_NUMBER_REGEX, { message: t('editor.validation.error.phoneInvalid') }),
49
+ })
50
+
51
+ const linkButtonSchema = z.object({
52
+ url: z.string().regex(VALID_URL_REGEX, { message: t('editor.validation.error.urlInvalid') }),
53
+ })
54
+ </script>
55
+
56
+ <template>
57
+ <div
58
+ class="space-y-3 divide-y divide-muted"
59
+ :data-testid="whatsappTranslatorButtonTestIds.container"
60
+ >
61
+ <!-- Loop through all buttons -->
62
+ <div
63
+ v-for="(button, index) in baseButtons"
64
+ :key="button.id"
65
+ class="space-y-2 pb-3 last:pb-0"
66
+ :data-testid="`${whatsappTranslatorButtonTestIds.buttonItem}-${button.id}`"
67
+ >
68
+ <!-- Button text field -->
69
+ <div class="flex gap-8 items-start">
70
+ <!-- Base language preview (read-only) -->
71
+ <div class="flex-1 min-w-0">
72
+ <div class="paragraph-sm text-primary-700 break-words">
73
+ {{ button.text }}
74
+ </div>
75
+ </div>
76
+
77
+ <!-- Translation editor (editable) -->
78
+ <div v-if="targetButtons[index]" class="flex-1 min-w-0 flex gap-2 items-start">
79
+ <div class="flex-1">
80
+ <UForm
81
+ :schema="buttonTextSchema"
82
+ :state="targetButtons[index]"
83
+ :validate-on="['blur', 'change']"
84
+ >
85
+ <UFormField
86
+ name="text"
87
+ :data-testid="`${whatsappTranslatorButtonTestIds.textField}-${button.id}`"
88
+ >
89
+ <UInput
90
+ v-model="targetButtons[index].text"
91
+ :placeholder="$t('editor.whatsapp.translations.placeholder')"
92
+ :maxlength="WA_BUTTON_TEXT_MAX_LENGTH"
93
+ :aria-describedby="`character-count-${index}`"
94
+ :ui="{ trailing: 'pointer-events-none' }"
95
+ :data-testid="`${whatsappTranslatorButtonTestIds.textInput}-${button.id}`"
96
+ >
97
+ <template #trailing>
98
+ <div
99
+ :id="`character-count-${index}`"
100
+ class="text-xs text-muted tabular-nums"
101
+ aria-live="polite"
102
+ role="status"
103
+ >
104
+ {{ targetButtons[index].text.length }}/{{ WA_BUTTON_TEXT_MAX_LENGTH }}
105
+ </div>
106
+ </template>
107
+ </UInput>
108
+ </UFormField>
109
+ </UForm>
110
+ </div>
111
+
112
+ <slot name="actions" />
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Button URL field (for link buttons) -->
117
+ <div
118
+ v-if="button.type === 'link' && 'url' in button && baseButtons[index]"
119
+ class="flex gap-8 items-start"
120
+ >
121
+ <!-- Base language preview (read-only) with copy button -->
122
+ <div class="flex-1 min-w-0">
123
+ <div class="flex items-center gap-2 paragraph-sm text-primary-700">
124
+ <span class="break-all">{{ button.url }}</span>
125
+ <UButton
126
+ color="primary"
127
+ variant="ghost"
128
+ icon="ph:copy"
129
+ size="xs"
130
+ @click="copyToClipboard(button.url || '')"
131
+ :data-testid="`${whatsappTranslatorButtonTestIds.copyUrlButton}-${button.id}`"
132
+ />
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Translation editor (editable) - no auto-translate for URLs -->
137
+ <div class="flex-1 min-w-0 flex gap-2 items-start">
138
+ <div class="flex-1">
139
+ <UForm
140
+ v-if="button.type === 'link'"
141
+ :schema="linkButtonSchema"
142
+ :state="targetButtons[index]"
143
+ :validate-on="['blur']"
144
+ >
145
+ <UFormField
146
+ name="url"
147
+ :data-testid="`${whatsappTranslatorButtonTestIds.urlField}-${button.id}`"
148
+ >
149
+ <UInput
150
+ v-model="(targetButtons[index] as any).url"
151
+ :placeholder="$t('editor.whatsapp.translations.placeholder')"
152
+ :data-testid="`${whatsappTranslatorButtonTestIds.urlInput}-${button.id}`"
153
+ />
154
+ </UFormField>
155
+ </UForm>
156
+ </div>
157
+ <!-- Empty space to align with text field above -->
158
+ <!-- <div class="w-[40px]"></div> -->
159
+ </div>
160
+ </div>
161
+ <!-- Button Phone Number field (for phone buttons) -->
162
+ <div
163
+ v-if="button.type === 'phone' && 'phoneNumber' in button && baseButtons[index]"
164
+ class="flex gap-8 items-start"
165
+ >
166
+ <!-- Base language preview (read-only) with copy button -->
167
+ <div class="flex-1 min-w-0">
168
+ <div class="flex items-center gap-2 paragraph-sm text-primary-700">
169
+ <span class="break-all">{{ button.phoneNumber }}</span>
170
+ <UButton
171
+ color="primary"
172
+ variant="ghost"
173
+ icon="ph:copy"
174
+ size="xs"
175
+ @click="copyToClipboard(button.phoneNumber || '')"
176
+ :data-testid="`${whatsappTranslatorButtonTestIds.copyPhoneButton}-${button.id}`"
177
+ />
178
+ </div>
179
+ </div>
180
+
181
+ <!-- Translation editor (editable) - no auto-translate for phone numbers -->
182
+ <div class="flex-1 min-w-0 flex gap-2 items-start">
183
+ <div class="flex-1">
184
+ <UForm
185
+ v-if="button.type === 'phone'"
186
+ :schema="phoneButtonSchema"
187
+ :state="targetButtons[index]"
188
+ :validate-on="['blur']"
189
+ >
190
+ <UFormField
191
+ name="phoneNumber"
192
+ :data-testid="`${whatsappTranslatorButtonTestIds.phoneField}-${button.id}`"
193
+ >
194
+ <PhoneInput
195
+ class="w-full"
196
+ v-model="(targetButtons[index] as any).phoneNumber"
197
+ default-prefix="39"
198
+ :data-testid="`${whatsappTranslatorButtonTestIds.phoneInput}-${button.id}`"
199
+ />
200
+ </UFormField>
201
+ </UForm>
202
+ </div>
203
+ <!-- Empty space to align with text field above -->
204
+ <!-- <div class="w-[40px]"></div> -->
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </template>
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ import { WA_FOOTER_MAX_LENGTH } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { useI18n } from 'vue-i18n'
4
+
5
+ defineOptions({
6
+ name: 'WhatsappTranslatorFooter',
7
+ })
8
+
9
+ const { baseContent } = defineProps<{
10
+ baseContent: string | undefined
11
+ }>()
12
+
13
+ const targetContent = defineModel<string>({
14
+ required: true,
15
+ })
16
+
17
+ const { t } = useI18n()
18
+ </script>
19
+
20
+ <template>
21
+ <div class="flex gap-8 items-start" :data-testid="whatsappTranslatorFooterTestIds.container">
22
+ <!-- Base language preview (read-only) -->
23
+ <div class="flex-1 min-w-0" :data-testid="whatsappTranslatorFooterTestIds.basePreview">
24
+ <div class="paragraph-sm text-primary-700 break-words">
25
+ {{ baseContent }}
26
+ </div>
27
+ </div>
28
+
29
+ <!-- Translation editor (editable) -->
30
+ <div class="flex-1 min-w-0 flex gap-2 items-start">
31
+ <div class="flex-1">
32
+ <UInput
33
+ v-model="targetContent"
34
+ :maxlength="WA_FOOTER_MAX_LENGTH"
35
+ :placeholder="$t('editor.whatsapp.translations.placeholder')"
36
+ aria-describedby="character-count"
37
+ :ui="{ trailing: 'pointer-events-none' }"
38
+ :data-testid="whatsappTranslatorFooterTestIds.targetInput"
39
+ >
40
+ <template #trailing>
41
+ <div
42
+ id="character-count"
43
+ class="text-xs text-muted tabular-nums"
44
+ aria-live="polite"
45
+ role="status"
46
+ >
47
+ {{ targetContent?.length }}/{{ WA_FOOTER_MAX_LENGTH }}
48
+ </div>
49
+ </template>
50
+ </UInput>
51
+ </div>
52
+
53
+ <slot name="actions" />
54
+ </div>
55
+ </div>
56
+ </template>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ import type { JSONContent } from '@tiptap/vue-3'
3
+ import { generateHTML } from '@tiptap/vue-3'
4
+ import { getTipTapExtensions } from '@dev.smartpricing/message-composer-utils/utils'
5
+ import RichTextEditor from '@/components/Editor/RichTextEditor.vue'
6
+ import { useAppContextStore } from '@/stores/appContext'
7
+ import { WA_HEADER_TEXT_MAX_LENGTH } from '@dev.smartpricing/message-composer-utils/types'
8
+
9
+ defineOptions({
10
+ name: 'WhatsappTranslatorHeader',
11
+ })
12
+
13
+ const { baseContent } = defineProps<{
14
+ baseContent: JSONContent | undefined
15
+ }>()
16
+
17
+ const targetContent = defineModel<JSONContent | undefined>({
18
+ required: true,
19
+ })
20
+
21
+ const appContext = useAppContextStore()
22
+
23
+ // Generate HTML for preview of base language with rich text support
24
+ function generatePreviewHtml(content: JSONContent | undefined): string {
25
+ if (!content) return ''
26
+ return generateHTML(content, getTipTapExtensions({ preset: 'whatsapp-header' })).replaceAll(
27
+ '<p></p>',
28
+ '<p>&nbsp;</p>',
29
+ )
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div class="flex gap-8 items-start" :data-testid="whatsappTranslatorHeaderTestIds.container">
35
+ <!-- Base language preview (read-only) -->
36
+ <div class="flex-1 min-w-0" :data-testid="whatsappTranslatorHeaderTestIds.basePreview">
37
+ <div
38
+ class="paragraph-sm text-primary-700 break-words"
39
+ v-html="generatePreviewHtml(baseContent)"
40
+ />
41
+ </div>
42
+
43
+ <!-- Translation editor (editable) -->
44
+ <div class="flex-1 min-w-0 flex gap-2 items-start">
45
+ <div class="flex-1">
46
+ <RichTextEditor
47
+ v-model:text="targetContent"
48
+ preset="whatsapp"
49
+ bold
50
+ italic
51
+ emoji
52
+ dynamic-values
53
+ :character-limit="WA_HEADER_TEXT_MAX_LENGTH"
54
+ :data-testid="whatsappTranslatorHeaderTestIds.targetEditor"
55
+ />
56
+ </div>
57
+
58
+ <slot name="actions" />
59
+ </div>
60
+ </div>
61
+ </template>
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ import type { MediaFromDb, WaHeaderTypeV1Url } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { computed } from 'vue'
4
+ import { useI18n } from 'vue-i18n'
5
+ import MediaUploadOrSelect from '@/components/Common/MediaUploadOrSelect.vue'
6
+
7
+ defineOptions({
8
+ name: 'WhatsappTranslatorHeaderMedia',
9
+ })
10
+
11
+ const { baseHeader } = defineProps<{
12
+ baseHeader: WaHeaderTypeV1Url
13
+ }>()
14
+
15
+ const model = defineModel<WaHeaderTypeV1Url>({ required: true })
16
+
17
+ const { t } = useI18n()
18
+
19
+ const isCustom = computed(() => model.value.url !== baseHeader.url)
20
+
21
+ function getBasename(url: string): string {
22
+ if (!url) return ''
23
+ try {
24
+ return decodeURIComponent(url.split('/').pop() || url)
25
+ } catch {
26
+ return url.split('/').pop() || url
27
+ }
28
+ }
29
+
30
+ function handleMediaSelected(media: MediaFromDb) {
31
+ model.value = {
32
+ ...model.value,
33
+ url: media.remote_url,
34
+ metaMediaId: media.meta_handle_id ?? '',
35
+ }
36
+ }
37
+
38
+ function resetToMain() {
39
+ model.value = {
40
+ ...model.value,
41
+ url: baseHeader.url,
42
+ metaMediaId: baseHeader.metaMediaId,
43
+ }
44
+ }
45
+
46
+ function clearMedia() {
47
+ model.value = {
48
+ ...model.value,
49
+ url: '',
50
+ metaMediaId: '',
51
+ }
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <div class="flex gap-8 items-start" :data-testid="whatsappTranslatorHeaderMediaTestIds.container">
57
+ <!-- Base language preview (read-only) -->
58
+ <div class="flex-1 min-w-0" :data-testid="whatsappTranslatorHeaderMediaTestIds.basePreview">
59
+ <div v-if="baseHeader.type === 'image' && baseHeader.url" class="max-w-[200px]">
60
+ <img
61
+ :src="baseHeader.url"
62
+ class="rounded border border-muted max-h-[120px] object-contain"
63
+ />
64
+ </div>
65
+ <div v-else-if="baseHeader.url" class="flex items-center gap-2 paragraph-sm text-primary-700">
66
+ <UIcon
67
+ :name="baseHeader.type === 'video' ? 'ph:video' : 'ph:file'"
68
+ class="w-5 h-5 shrink-0"
69
+ />
70
+ <span class="truncate">{{ getBasename(baseHeader.url) }}</span>
71
+ </div>
72
+ <div v-else class="paragraph-sm text-muted">
73
+ {{ t('editor.whatsapp.header.types.' + baseHeader.type) }}
74
+ </div>
75
+ </div>
76
+
77
+ <!-- Translation editor (editable) -->
78
+ <div class="flex-1 min-w-0">
79
+ <!-- Upload/Select + actions -->
80
+ <div class="flex items-center gap-2">
81
+ <MediaUploadOrSelect
82
+ :key="model.type"
83
+ v-model="model.url"
84
+ :media-type="model.type"
85
+ class="flex-1"
86
+ show-preview
87
+ @media-selected="handleMediaSelected"
88
+ :data-testid="whatsappTranslatorHeaderMediaTestIds.mediaUpload"
89
+ >
90
+ <template #actions>
91
+ <UButton
92
+ v-if="model.url"
93
+ color="error"
94
+ variant="ghost"
95
+ icon="ph:trash"
96
+ @click="clearMedia"
97
+ :data-testid="whatsappTranslatorHeaderMediaTestIds.clearMediaButton"
98
+ />
99
+ <UTooltip
100
+ :text="t('editor.whatsapp.translator.reset_to_main')"
101
+ :data-testid="whatsappTranslatorHeaderMediaTestIds.resetTooltip"
102
+ >
103
+ <UButton
104
+ v-if="isCustom"
105
+ color="warning"
106
+ variant="ghost"
107
+ icon="ph:arrow-counter-clockwise-thin"
108
+ @click="resetToMain"
109
+ :data-testid="whatsappTranslatorHeaderMediaTestIds.resetButton"
110
+ />
111
+ </UTooltip>
112
+ </template>
113
+ </MediaUploadOrSelect>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </template>
@@ -0,0 +1,159 @@
1
+ <script setup lang="ts">
2
+ import type { WhatsappMessage, PostEntity } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { computed } from 'vue'
4
+ import Header from './Header.vue'
5
+ import HeaderMedia from './HeaderMedia.vue'
6
+ import Body from './Body.vue'
7
+ import Footer from './Footer.vue'
8
+ import Button from './Button.vue'
9
+ import { getLanguageDisplayName } from '@dev.smartpricing/message-composer-utils/utils'
10
+
11
+ defineOptions({
12
+ name: 'WhatsappTranslator',
13
+ })
14
+
15
+ interface Props {
16
+ baseMessage: WhatsappMessage | PostEntity<WhatsappMessage>
17
+ filteredSections?: Array<'header' | 'body' | 'footer' | 'buttons'>
18
+ }
19
+
20
+ const props = defineProps<Props>()
21
+
22
+ const targetMessage = defineModel<WhatsappMessage | PostEntity<WhatsappMessage>>('targetMessage', {
23
+ required: true,
24
+ })
25
+
26
+ type Section = {
27
+ title: string
28
+ component: 'header' | 'body' | 'footer' | 'buttons'
29
+ }
30
+
31
+ // Compute enabled sections from base message
32
+ const allSections = computed(() => {
33
+ const result: Section[] = []
34
+
35
+ // Header section (text or media)
36
+ if (props.baseMessage.body.header && props.baseMessage.body.header.type !== 'none') {
37
+ result.push({
38
+ title: t('editor.whatsapp.translator.header'),
39
+ component: 'header',
40
+ })
41
+ }
42
+
43
+ // Body section (always required)
44
+ if (props.baseMessage.body.body) {
45
+ result.push({
46
+ title: t('editor.whatsapp.translator.body'),
47
+ component: 'body',
48
+ })
49
+ }
50
+
51
+ // Footer section
52
+ if (props.baseMessage.body.footer?.enabled) {
53
+ result.push({
54
+ title: t('editor.whatsapp.translator.footer'),
55
+ component: 'footer',
56
+ })
57
+ }
58
+
59
+ // Buttons section (all buttons in one section)
60
+ if (props.baseMessage.body.buttons.buttons && props.baseMessage.body.buttons.buttons.length > 0) {
61
+ result.push({
62
+ title: t('editor.whatsapp.translator.button'),
63
+ component: 'buttons',
64
+ })
65
+ }
66
+
67
+ return result
68
+ })
69
+
70
+ // Apply filtering if filteredSections prop is provided
71
+ const sections = computed(() => {
72
+ if (!props.filteredSections) {
73
+ return allSections.value
74
+ }
75
+
76
+ return allSections.value.filter((section) => props.filteredSections!.includes(section.component))
77
+ })
78
+
79
+ const { locale, t } = useI18n()
80
+ </script>
81
+
82
+ <template>
83
+ <div class="p-4 space-y-2" :data-testid="whatsappTranslatorIndexTestIds.container">
84
+ <!-- Translation cards for each section -->
85
+ <div
86
+ v-for="(section, sectionIndex) in sections"
87
+ :key="sectionIndex"
88
+ class="bg-white border border-muted rounded-lg overflow-hidden"
89
+ :data-testid="`${whatsappTranslatorIndexTestIds.sectionCard}-${section.component}`"
90
+ >
91
+ <!-- Card header with title -->
92
+ <div class="px-4 py-3 border-b border-muted">
93
+ <h4 class="label-sm">{{ section.title }}</h4>
94
+ </div>
95
+
96
+ <!-- Card content -->
97
+ <div class="px-px">
98
+ <!-- Column headers -->
99
+ <div class="bg-muted px-4 py-2 flex gap-8">
100
+ <div class="flex-1 min-w-0">
101
+ <div class="flex items-center gap-1">
102
+ <p class="label-xs text-primary-700">
103
+ {{ getLanguageDisplayName(baseMessage.language_id, locale) }}
104
+ </p>
105
+ </div>
106
+ </div>
107
+ <div class="flex-1 min-w-0">
108
+ <div class="flex items-center gap-1">
109
+ <p class="label-xs text-primary-700">
110
+ {{ getLanguageDisplayName(targetMessage.language_id, locale) }}
111
+ </p>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Content row -->
117
+ <div class="px-4 py-3" :key="targetMessage.language_id">
118
+ <!-- Header text translator -->
119
+ <Header
120
+ v-if="section.component === 'header' && baseMessage.body.header.type === 'text'"
121
+ :base-content="baseMessage.body.header.content"
122
+ v-model="(targetMessage.body.header as any).content"
123
+ />
124
+
125
+ <!-- Header media translator -->
126
+ <HeaderMedia
127
+ v-else-if="
128
+ section.component === 'header' &&
129
+ ['image', 'video', 'document'].includes(baseMessage.body.header.type)
130
+ "
131
+ :base-header="baseMessage.body.header as any"
132
+ v-model="targetMessage.body.header as any"
133
+ />
134
+
135
+ <!-- Body translator -->
136
+ <Body
137
+ v-else-if="section.component === 'body'"
138
+ :base-content="baseMessage.body.body.content"
139
+ v-model="targetMessage.body.body.content"
140
+ />
141
+
142
+ <!-- Footer translator -->
143
+ <Footer
144
+ v-else-if="section.component === 'footer'"
145
+ :base-content="baseMessage.body.footer?.content"
146
+ v-model="targetMessage.body.footer!.content"
147
+ />
148
+
149
+ <!-- Buttons translator (all buttons) -->
150
+ <Button
151
+ v-else-if="section.component === 'buttons'"
152
+ :base-buttons="baseMessage.body.buttons.buttons"
153
+ v-model="targetMessage.body.buttons.buttons"
154
+ />
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </template>
@@ -0,0 +1,35 @@
1
+ import type { InjectionKey, Ref } from 'vue'
2
+ import type { AppContextConfig } from '@dev.smartpricing/message-composer-utils/types'
3
+
4
+ export interface ComposerContext {
5
+ authenticationToken: Ref<string>
6
+ owner_id?: Ref<string>
7
+ product?: Ref<AppContextConfig['product']>
8
+ strategy?: Ref<AppContextConfig['strategy']>
9
+ channels?: Ref<AppContextConfig['channels']>
10
+ languages?: Ref<string[]>
11
+ mainLanguage?: Ref<string>
12
+ dinamicValues?: Ref<Array<{ value: string; label: string }>>
13
+ default_tags?: Ref<string[]>
14
+ metaWabaId?: Ref<string>
15
+ metaAccessToken?: Ref<string>
16
+ }
17
+
18
+ export const COMPOSER_CONTEXT_KEY: InjectionKey<ComposerContext> = Symbol('composer-context')
19
+
20
+ /** Must be called in parent's app.vue or plugin before layer components render. */
21
+ export function defineComposerContext(context: ComposerContext): void {
22
+ // Use app-level provide so Pinia stores (which run in their own effectScope) can inject it.
23
+ useNuxtApp().vueApp.provide(COMPOSER_CONTEXT_KEY, context)
24
+ }
25
+
26
+ export function useComposerContext(): ComposerContext {
27
+ const context = inject(COMPOSER_CONTEXT_KEY)
28
+ if (!context) {
29
+ throw new Error(
30
+ '[message-composer] ComposerContext not provided. ' +
31
+ 'Call defineComposerContext() in your app.vue or a plugin before using layer components.',
32
+ )
33
+ }
34
+ return context
35
+ }