@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,341 @@
1
+ <script lang="ts" setup>
2
+ import ViewLessMore from '@/components/Common/ViewLessMore.vue'
3
+
4
+ import type {
5
+ ButtonBlockV1,
6
+ WaButtonV1,
7
+ WaButtonTypeV1,
8
+ ManagedLocales,
9
+ } from '@dev.smartpricing/message-composer-utils/types'
10
+ import {
11
+ WA_BUTTON_TYPES_V1,
12
+ WA_BUTTON_TEXT_MAX_LENGTH,
13
+ WA_BUTTON_MAX_LINK_BUTTONS,
14
+ WA_BUTTON_MAX_PHONE_BUTTONS,
15
+ WA_BUTTON_MAX_BUTTONS,
16
+ WA_BUTTON_MAX_UNSUBSCRIBE_BUTTONS,
17
+ WA_BUTTON_MAX_QUICK_REPLY_BUTTONS,
18
+ } from '@dev.smartpricing/message-composer-utils/types'
19
+ import { extractFromLocale } from '@/utils/locale'
20
+ import { computed, watch } from 'vue'
21
+ import { useI18n } from 'vue-i18n'
22
+ import type { DropdownSectionAction } from '#ui/types'
23
+ import PhoneInput from '@/components/Common/PhoneInput.vue'
24
+ import { z } from 'zod'
25
+ import {
26
+ VALID_PHONE_NUMBER_REGEX,
27
+ VALID_URL_REGEX,
28
+ } from '@dev.smartpricing/message-composer-utils/utils'
29
+
30
+ defineOptions({
31
+ name: 'WhatsappButtons',
32
+ })
33
+
34
+ const { locale } = defineProps<{
35
+ locale: ManagedLocales
36
+ }>()
37
+
38
+ const emit = defineEmits<{
39
+ buttonAdded: [button: WaButtonV1]
40
+ buttonRemoved: [buttonId: string]
41
+ buttonTypeChanged: [button: WaButtonV1]
42
+ }>()
43
+
44
+ const { t } = useI18n()
45
+ const model = defineModel<ButtonBlockV1>({ required: true })
46
+
47
+ const buttonTypes = computed<DropdownSectionAction[]>(() =>
48
+ WA_BUTTON_TYPES_V1.map((type) => ({
49
+ value: type,
50
+ label: t(`editor.whatsapp.buttons.button_types.${type}`),
51
+ disabled:
52
+ (type === 'unsubscribe' &&
53
+ unsubscribeButtonCount.value >= WA_BUTTON_MAX_UNSUBSCRIBE_BUTTONS) ||
54
+ (type === 'phone' && phoneButtonCount.value >= WA_BUTTON_MAX_PHONE_BUTTONS) ||
55
+ (type === 'link' && linkButtonCount.value >= WA_BUTTON_MAX_LINK_BUTTONS) ||
56
+ (type === 'quick_reply' && quickReplyButtonCount.value >= WA_BUTTON_MAX_QUICK_REPLY_BUTTONS),
57
+ })),
58
+ )
59
+
60
+ const addButtonActions = computed<DropdownSectionAction[]>(() => [
61
+ {
62
+ label: t('editor.whatsapp.buttons.button_types.phone'),
63
+ onClick: () => addButton('phone'),
64
+ disabled: phoneButtonCount.value >= WA_BUTTON_MAX_PHONE_BUTTONS,
65
+ },
66
+ {
67
+ label: t('editor.whatsapp.buttons.button_types.unsubscribe'),
68
+ onClick: () => addButton('unsubscribe'),
69
+ disabled: unsubscribeButtonCount.value >= WA_BUTTON_MAX_UNSUBSCRIBE_BUTTONS,
70
+ },
71
+ {
72
+ label: t('editor.whatsapp.buttons.button_types.link'),
73
+ onClick: () => addButton('link'),
74
+ disabled: linkButtonCount.value >= WA_BUTTON_MAX_LINK_BUTTONS,
75
+ },
76
+ {
77
+ label: t('editor.whatsapp.buttons.button_types.quick_reply'),
78
+ onClick: () => addButton('quick_reply'),
79
+ disabled: quickReplyButtonCount.value >= WA_BUTTON_MAX_QUICK_REPLY_BUTTONS,
80
+ },
81
+ ])
82
+
83
+ async function addButton(type: WaButtonTypeV1) {
84
+ let text = ''
85
+ if (type === 'phone') {
86
+ text = await extractFromLocale('editor.whatsapp.buttons.default_text.phone', locale)
87
+ } else if (type === 'link') {
88
+ text = await extractFromLocale('editor.whatsapp.buttons.default_text.link', locale)
89
+ } else if (type === 'unsubscribe') {
90
+ text = await extractFromLocale('editor.whatsapp.buttons.default_text.unsubscribe', locale)
91
+ }
92
+
93
+ const newButton: WaButtonV1 = {
94
+ id: crypto.randomUUID(),
95
+ type,
96
+ text,
97
+ ...(type === 'phone' && { phoneNumber: '' }),
98
+ ...(type === 'link' && { url: 'https://' }),
99
+ } as WaButtonV1
100
+
101
+ model.value.buttons.push(newButton)
102
+ emit('buttonAdded', newButton)
103
+ }
104
+
105
+ function removeButton(button: WaButtonV1) {
106
+ model.value.buttons = model.value.buttons.filter((btn) => btn.id !== button.id)
107
+ emit('buttonRemoved', button.id)
108
+ }
109
+
110
+ function updateButtonType(button: WaButtonV1, index: number, type: WaButtonTypeV1) {
111
+ // Reset other values when type changes
112
+ switch (type) {
113
+ case 'phone':
114
+ button = {
115
+ text: button.text,
116
+ type: 'phone',
117
+ phoneNumber: '',
118
+ id: button.id,
119
+ }
120
+
121
+ break
122
+ case 'link':
123
+ button = {
124
+ text: button.text,
125
+ type: 'link',
126
+ url: 'https://',
127
+ id: button.id,
128
+ }
129
+ break
130
+ case 'unsubscribe':
131
+ button = {
132
+ text: button.text,
133
+ type: 'unsubscribe',
134
+ id: button.id,
135
+ }
136
+ break
137
+ case 'quick_reply':
138
+ button = {
139
+ text: button.text,
140
+ type: 'quick_reply',
141
+ id: button.id,
142
+ }
143
+ break
144
+ default:
145
+ break
146
+ }
147
+
148
+ model.value.buttons.splice(index, 1, button)
149
+ emit('buttonTypeChanged', button)
150
+ }
151
+
152
+ const phoneButtonCount = computed(
153
+ () => model.value.buttons.filter((btn) => btn.type === 'phone').length,
154
+ )
155
+
156
+ const unsubscribeButtonCount = computed(
157
+ () => model.value.buttons.filter((btn) => btn.type === 'unsubscribe').length,
158
+ )
159
+
160
+ const linkButtonCount = computed(
161
+ () => model.value.buttons.filter((btn) => btn.type === 'link').length,
162
+ )
163
+
164
+ const quickReplyButtonCount = computed(
165
+ () => model.value.buttons.filter((btn) => btn.type === 'quick_reply').length,
166
+ )
167
+
168
+ const buttonsCount = computed(() => model.value.buttons.length)
169
+
170
+ // Validation schema for button fields
171
+ const buttonTextSchema = z.object({
172
+ text: z.string().max(WA_BUTTON_TEXT_MAX_LENGTH, {
173
+ message: t('editor.validation.error.textTooLong'),
174
+ }),
175
+ })
176
+
177
+ const phoneButtonSchema = z.object({
178
+ phoneNumber: z
179
+ .string()
180
+ .regex(VALID_PHONE_NUMBER_REGEX, { message: t('editor.validation.error.phoneInvalid') }),
181
+ })
182
+
183
+ const linkButtonSchema = z.object({
184
+ url: z.string().regex(VALID_URL_REGEX, { message: t('editor.validation.error.urlInvalid') }),
185
+ })
186
+
187
+ // Watch for button changes to update enabled state
188
+ watch(
189
+ () => model.value.buttons.length,
190
+ () => {
191
+ model.value.enabled = model.value.buttons.length > 0
192
+ },
193
+ { immediate: true },
194
+ )
195
+ </script>
196
+
197
+ <template>
198
+ <div :data-testid="whatsappEditorButtonsTestIds.container">
199
+ <!-- Title and Description Section -->
200
+ <section class="pb-3">
201
+ <div class="flex justify-between items-center pb-3">
202
+ <h4 class="label-md">{{ $t('editor.whatsapp.buttons.title') }}</h4>
203
+ <UDropdownMenu
204
+ :items="addButtonActions"
205
+ :data-testid="whatsappEditorButtonsTestIds.addButtonDropdown"
206
+ >
207
+ <UButton
208
+ variant="outline"
209
+ color="primary"
210
+ :disabled="buttonsCount >= WA_BUTTON_MAX_BUTTONS"
211
+ :data-testid="whatsappEditorButtonsTestIds.addButton"
212
+ >
213
+ {{ t('editor.whatsapp.buttons.add_button') }}
214
+ </UButton>
215
+ </UDropdownMenu>
216
+ </div>
217
+ <div class="max-w-[400px]">
218
+ <p
219
+ class="paragraph-sm pb-2 text-primary-800"
220
+ v-html="$t('editor.whatsapp.buttons.description_main')"
221
+ />
222
+ <ViewLessMore>
223
+ <ul class="list-disc list-inside text-sm space-y-2 text-primary-800 pb-2">
224
+ <li
225
+ class="paragraph-sm"
226
+ v-html="$t('editor.whatsapp.buttons.description_unsubscribe')"
227
+ />
228
+ <li class="paragraph-sm" v-html="$t('editor.whatsapp.buttons.description_phone')" />
229
+ <li class="paragraph-sm" v-html="$t('editor.whatsapp.buttons.description_link')" />
230
+ <li
231
+ class="paragraph-sm"
232
+ v-html="$t('editor.whatsapp.buttons.description_quick_reply')"
233
+ />
234
+ </ul>
235
+ </ViewLessMore>
236
+ </div>
237
+ </section>
238
+
239
+ <!-- Add Button Dropdown -->
240
+
241
+ <!-- Button Configuration Grid -->
242
+ <div class="space-y-4">
243
+ <div
244
+ v-for="(button, index) in model.buttons"
245
+ :key="button.id"
246
+ class="flex gap-2"
247
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonItem}-${button.id}`"
248
+ >
249
+ <!-- Type Select -->
250
+ <div class="w-1/3">
251
+ <USelect
252
+ :model-value="button.type"
253
+ :items="buttonTypes"
254
+ @update:model-value="updateButtonType(button, index, $event as WaButtonTypeV1)"
255
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonTypeSelect}-${button.id}`"
256
+ />
257
+ </div>
258
+
259
+ <!-- Text Input -->
260
+ <div class="flex-1 space-y-2">
261
+ <UForm :schema="buttonTextSchema" :state="button" :validate-on="['input', 'blur']">
262
+ <UFormField
263
+ name="text"
264
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonTextField}-${button.id}`"
265
+ >
266
+ <UInput
267
+ v-model="button.text"
268
+ :placeholder="t('editor.whatsapp.buttons.placeholders.text')"
269
+ :maxlength="WA_BUTTON_TEXT_MAX_LENGTH"
270
+ :aria-describedby="`character-count-${index}`"
271
+ :ui="{ trailing: 'pointer-events-none' }"
272
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonTextInput}-${button.id}`"
273
+ >
274
+ <template #trailing>
275
+ <div
276
+ :id="`character-count-${index}`"
277
+ class="text-xs text-muted tabular-nums"
278
+ aria-live="polite"
279
+ role="status"
280
+ >
281
+ {{ button.text?.length }}/{{ WA_BUTTON_TEXT_MAX_LENGTH }}
282
+ </div>
283
+ </template>
284
+ </UInput>
285
+ </UFormField>
286
+ </UForm>
287
+
288
+ <!-- Phone Number Field -->
289
+ <UForm
290
+ v-if="button.type === 'phone'"
291
+ :schema="phoneButtonSchema"
292
+ :state="button"
293
+ :validate-on="['blur']"
294
+ >
295
+ <UFormField
296
+ name="phoneNumber"
297
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonPhoneField}-${button.id}`"
298
+ >
299
+ <PhoneInput
300
+ class="w-full"
301
+ v-model="button.phoneNumber"
302
+ default-prefix="39"
303
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonPhoneInput}-${button.id}`"
304
+ />
305
+ </UFormField>
306
+ </UForm>
307
+ <!-- URL Field -->
308
+ <UForm
309
+ v-else-if="button.type === 'link'"
310
+ :schema="linkButtonSchema"
311
+ :state="button"
312
+ :validate-on="['blur']"
313
+ >
314
+ <UFormField
315
+ name="url"
316
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonUrlField}-${button.id}`"
317
+ >
318
+ <UInput
319
+ v-model="button.url"
320
+ :placeholder="t('editor.whatsapp.buttons.placeholders.url')"
321
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonUrlInput}-${button.id}`"
322
+ />
323
+ </UFormField>
324
+ </UForm>
325
+ </div>
326
+
327
+ <!-- Delete Button -->
328
+ <div>
329
+ <UButton
330
+ variant="ghost"
331
+ color="error"
332
+ size="sm"
333
+ icon="ph:trash"
334
+ @click="removeButton(button)"
335
+ :data-testid="`${whatsappEditorButtonsTestIds.buttonRemoveButton}-${button.id}`"
336
+ />
337
+ </div>
338
+ </div>
339
+ </div>
340
+ </div>
341
+ </template>
@@ -0,0 +1,70 @@
1
+ <script lang="ts" setup>
2
+ import {
3
+ WA_FOOTER_MAX_LENGTH,
4
+ type FooterBlockV1,
5
+ } from '@dev.smartpricing/message-composer-utils/types'
6
+ import { watch } from 'vue'
7
+ import { useI18n } from 'vue-i18n'
8
+
9
+ defineOptions({
10
+ name: 'WhatsappEditorFooter',
11
+ })
12
+
13
+ const { t } = useI18n()
14
+ const model = defineModel<FooterBlockV1>({ required: true })
15
+
16
+ const emit = defineEmits<{
17
+ footerEnabledChanged: [enabled: boolean]
18
+ }>()
19
+
20
+ watch(
21
+ () => model.value.content,
22
+ (newContent) => {
23
+ const newEnabled = newContent.length > 0
24
+ const changed = model.value.enabled !== newEnabled
25
+ model.value.enabled = newEnabled
26
+
27
+ if (changed) {
28
+ emit('footerEnabledChanged', newEnabled)
29
+ }
30
+ },
31
+ )
32
+ </script>
33
+
34
+ <template>
35
+ <div :data-testid="whatsappEditorFooterTestIds.container">
36
+ <section class="max-w-[400px] pb-3">
37
+ <h4 class="label-md pb-3">{{ $t('editor.whatsapp.footer.title') }}</h4>
38
+ <p
39
+ class="paragraph-sm text-primary-800"
40
+ v-html="$t('editor.whatsapp.footer.description_main')"
41
+ />
42
+ </section>
43
+
44
+ <UFormField
45
+ :help="t('editor.validation.character_limit')"
46
+ required
47
+ :data-testid="whatsappEditorFooterTestIds.contentField"
48
+ >
49
+ <UInput
50
+ v-model="model.content"
51
+ :placeholder="$t('editor.whatsapp.footer.placeholder')"
52
+ :maxLength="WA_FOOTER_MAX_LENGTH"
53
+ aria-describedby="character-count"
54
+ :ui="{ trailing: 'pointer-events-none' }"
55
+ :data-testid="whatsappEditorFooterTestIds.contentInput"
56
+ >
57
+ <template #trailing>
58
+ <div
59
+ id="character-count"
60
+ class="text-xs text-muted tabular-nums"
61
+ aria-live="polite"
62
+ role="status"
63
+ >
64
+ {{ model.content?.length }}/{{ WA_FOOTER_MAX_LENGTH }}
65
+ </div>
66
+ </template>
67
+ </UInput>
68
+ </UFormField>
69
+ </div>
70
+ </template>
@@ -0,0 +1,156 @@
1
+ <script lang="ts" setup>
2
+ import {
3
+ WA_HEADER_TEXT_MAX_LENGTH,
4
+ WA_HEADER_TYPES_V1,
5
+ type HeaderBlockV1,
6
+ type MediaFromDb,
7
+ type WaHeaderTypeV1Url,
8
+ } from '@dev.smartpricing/message-composer-utils/types'
9
+ import { computed, watch, ref, onMounted } from 'vue'
10
+ import { useI18n } from 'vue-i18n'
11
+ import RichTextEditor from '@/components/Editor/RichTextEditor.vue'
12
+ import MediaUploadOrSelect from '@/components/Common/MediaUploadOrSelect.vue'
13
+ import { useAppContextStore } from '@/stores/appContext'
14
+
15
+ defineOptions({
16
+ name: 'WhatsappEditorHeader',
17
+ })
18
+
19
+ const emit = defineEmits<{
20
+ headerTypeChanged: [header: HeaderBlockV1]
21
+ headerMediaChanged: [payload: { header: HeaderBlockV1; previousUrl: string }]
22
+ }>()
23
+
24
+ const { t } = useI18n()
25
+
26
+ const model = defineModel<HeaderBlockV1>({ required: true })
27
+ const appContext = useAppContextStore()
28
+
29
+ const initialUrl = ref<string>('')
30
+ onMounted(() => {
31
+ if (isMultimedia(model.value)) {
32
+ initialUrl.value = model.value.url
33
+ }
34
+ })
35
+
36
+ // Convert header types to options for Select component
37
+ const headerTypeOptions = computed(() =>
38
+ WA_HEADER_TYPES_V1.map((type) => ({
39
+ value: type,
40
+ label: t(`editor.whatsapp.header.types.${type}`),
41
+ })),
42
+ )
43
+
44
+ // Reset state when type changes
45
+ watch(
46
+ () => model.value.type,
47
+ (newType, oldType) => {
48
+ let newHeader: HeaderBlockV1 = { type: newType } as HeaderBlockV1
49
+ if (newType === 'none') {
50
+ newHeader = { type: 'none' }
51
+ } else if (newType === 'text') {
52
+ newHeader = { type: 'text', content: undefined }
53
+ } else if (['video', 'document', 'image'].includes(newType)) {
54
+ newHeader = {
55
+ type: newType,
56
+ url: '',
57
+ smartchatMediaId: '',
58
+ }
59
+ }
60
+ model.value = newHeader
61
+
62
+ // Emit event when type changes (skip initial mount)
63
+ if (oldType !== undefined) {
64
+ emit('headerTypeChanged', newHeader)
65
+ }
66
+ },
67
+ )
68
+
69
+ // Media handling
70
+ function handleMediaSelected(media: MediaFromDb) {
71
+ if (!isMultimedia(model.value)) return
72
+ const previousUrl = model.value.url
73
+ model.value.url = media.remote_url
74
+ model.value.smartchatMediaId = media.smartchat_media_id ?? ''
75
+ emit('headerMediaChanged', { header: model.value, previousUrl })
76
+ }
77
+
78
+ function handleMediaDeleted() {
79
+ if (!isMultimedia(model.value)) return
80
+ const previousUrl = model.value.url
81
+ model.value.url = ''
82
+ model.value.smartchatMediaId = ''
83
+ emit('headerMediaChanged', { header: model.value, previousUrl })
84
+ }
85
+
86
+ function handleMediaRestored() {
87
+ if (!isMultimedia(model.value)) return
88
+ const previousUrl = model.value.url
89
+ model.value.url = initialUrl.value
90
+ emit('headerMediaChanged', { header: model.value, previousUrl })
91
+ }
92
+
93
+ function isMultimedia(model: HeaderBlockV1): model is WaHeaderTypeV1Url {
94
+ return ['video', 'document', 'image'].includes(model.type)
95
+ }
96
+ </script>
97
+
98
+ <template>
99
+ <div :data-testid="whatsappEditorHeaderTestIds.container">
100
+ <section class="max-w-[400px] pb-3">
101
+ <h4 class="label-md">{{ $t('editor.whatsapp.header.title') }}</h4>
102
+ </section>
103
+ <USelect
104
+ v-model="model.type"
105
+ class="max-w-1/2"
106
+ :items="headerTypeOptions"
107
+ :placeholder="$t('editor.whatsapp.header.type_placeholder')"
108
+ :data-testid="whatsappEditorHeaderTestIds.typeSelect"
109
+ />
110
+
111
+ <!-- Text Editor -->
112
+ <div v-if="model.type === 'text'" class="mt-3">
113
+ <RichTextEditor
114
+ v-model:text="model.content"
115
+ preset="whatsapp"
116
+ bold
117
+ italic
118
+ emoji
119
+ improve
120
+ :character-limit="WA_HEADER_TEXT_MAX_LENGTH"
121
+ dynamic-values
122
+ :data-testid="whatsappEditorHeaderTestIds.textEditor"
123
+ />
124
+ </div>
125
+
126
+ <!-- Media Uploader -->
127
+ <div v-if="isMultimedia(model)" class="mt-4">
128
+ <MediaUploadOrSelect
129
+ :key="model.type"
130
+ v-model="model.url"
131
+ :media-type="model.type"
132
+ @media-selected="handleMediaSelected"
133
+ :data-testid="whatsappEditorHeaderTestIds.mediaUpload"
134
+ >
135
+ <template #actions>
136
+ <UButton
137
+ v-if="model.url"
138
+ color="error"
139
+ variant="ghost"
140
+ icon="ph:trash"
141
+ @click.stop="handleMediaDeleted"
142
+ :data-testid="whatsappEditorHeaderTestIds.mediaDeleteButton"
143
+ />
144
+ <UButton
145
+ v-if="initialUrl && model.url !== initialUrl"
146
+ color="warning"
147
+ variant="ghost"
148
+ icon="ph:arrow-counter-clockwise-thin"
149
+ @click.stop="handleMediaRestored"
150
+ :data-testid="whatsappEditorHeaderTestIds.mediaRestoreButton"
151
+ />
152
+ </template>
153
+ </MediaUploadOrSelect>
154
+ </div>
155
+ </div>
156
+ </template>
@@ -0,0 +1,89 @@
1
+ <script lang="ts" setup>
2
+ import type { JSONContent } from '@tiptap/vue-3'
3
+ import type {
4
+ PostEntity,
5
+ WhatsappMessage,
6
+ WaButtonV1,
7
+ HeaderBlockV1,
8
+ } from '@dev.smartpricing/message-composer-utils/types'
9
+ import WhatsappEditorHeader from './Header.vue'
10
+ import WhatsappEditorBody from './Body.vue'
11
+ import WhatsappEditorFooter from './Footer.vue'
12
+ import WhatsappEditorButtons from './Buttons.vue'
13
+
14
+ const model = defineModel<PostEntity<WhatsappMessage>>({ required: true })
15
+
16
+ const emit = defineEmits<{
17
+ buttonAdded: [button: WaButtonV1]
18
+ buttonRemoved: [buttonId: string]
19
+ buttonTypeChanged: [button: WaButtonV1]
20
+ headerTypeChanged: [header: HeaderBlockV1]
21
+ headerMediaChanged: [payload: { header: HeaderBlockV1; previousUrl: string }]
22
+ footerEnabledChanged: [enabled: boolean]
23
+ }>()
24
+
25
+ defineOptions({
26
+ name: 'WhatsappEditorIndex',
27
+ })
28
+
29
+ function extractText(node: JSONContent | undefined): string {
30
+ if (!node) return ''
31
+ let text = ''
32
+ if (node.text) text += node.text
33
+ if (node.content) {
34
+ for (const child of node.content) {
35
+ text += extractText(child)
36
+ }
37
+ }
38
+ return text
39
+ }
40
+
41
+ const bodyGenerateContext = computed(() => {
42
+ const parts: string[] = []
43
+ const header = model.value.body.header
44
+ if (header.type === 'text') {
45
+ const text = extractText(header.content).trim()
46
+ if (text) parts.push(`[header]: ${text}`)
47
+ }
48
+ const footer = model.value.body.footer
49
+ if (footer.enabled && footer.content?.trim()) {
50
+ parts.push(`[footer]: ${footer.content.trim()}`)
51
+ }
52
+ return parts.join('\n') || undefined
53
+ })
54
+ </script>
55
+
56
+ <template>
57
+ <div :data-testid="whatsappEditorIndexTestIds.container">
58
+ <WhatsappEditorHeader
59
+ class="px-4"
60
+ v-model="model.body.header"
61
+ :locale="model.language_id"
62
+ @header-type-changed="emit('headerTypeChanged', $event)"
63
+ @header-media-changed="emit('headerMediaChanged', $event)"
64
+ />
65
+ <USeparator class="my-6" color="neutral" />
66
+ <WhatsappEditorBody
67
+ class="px-4"
68
+ v-model="model.body.body"
69
+ :locale="model.language_id"
70
+ :generate-context="bodyGenerateContext"
71
+ />
72
+ <USeparator class="my-6" color="neutral" />
73
+ <WhatsappEditorFooter
74
+ class="px-4"
75
+ v-model="model.body.footer"
76
+ :locale="model.language_id"
77
+ @footer-enabled-changed="emit('footerEnabledChanged', $event)"
78
+ />
79
+ <USeparator class="my-6" color="neutral" />
80
+ <WhatsappEditorButtons
81
+ class="px-4"
82
+ v-model="model.body.buttons"
83
+ :locale="model.language_id"
84
+ @button-added="emit('buttonAdded', $event)"
85
+ @button-removed="emit('buttonRemoved', $event)"
86
+ @button-type-changed="emit('buttonTypeChanged', $event)"
87
+ />
88
+ </div>
89
+ </template>