@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.
- package/README.md +75 -0
- package/app/api/ai.ts +28 -0
- package/app/api/brands.ts +54 -0
- package/app/api/client.ts +56 -0
- package/app/api/compilation.ts +34 -0
- package/app/api/dynamicValues.ts +11 -0
- package/app/api/index.ts +11 -0
- package/app/api/languages.ts +6 -0
- package/app/api/media.ts +26 -0
- package/app/api/messageGroups.ts +49 -0
- package/app/api/messages.ts +22 -0
- package/app/api/meta.ts +18 -0
- package/app/api/tracking.ts +9 -0
- package/app/components/Brand/BrandDrawer.vue +281 -0
- package/app/components/Brand/BrandPreview.vue +137 -0
- package/app/components/Brand/Steps/BrandStepGeneral.vue +37 -0
- package/app/components/Brand/Steps/BrandStepSocials.vue +48 -0
- package/app/components/Brand/Steps/BrandStepVisual.vue +214 -0
- package/app/components/Common/BrandColorSwatches.vue +37 -0
- package/app/components/Common/ColorInput.vue +144 -0
- package/app/components/Common/EmojiPickerButton.vue +35 -0
- package/app/components/Common/FontFamilySelect.vue +39 -0
- package/app/components/Common/HtmlPreviewIframe.vue +68 -0
- package/app/components/Common/IncompleteTranslationsDialog.vue +97 -0
- package/app/components/Common/LanguageList.vue +356 -0
- package/app/components/Common/LoadingOverlay.vue +12 -0
- package/app/components/Common/MediaUploadOrSelect.vue +299 -0
- package/app/components/Common/MessagePreviewSlideover.vue +88 -0
- package/app/components/Common/MonacoEditor.vue +110 -0
- package/app/components/Common/PhoneInput.vue +804 -0
- package/app/components/Common/SectionHeader.vue +26 -0
- package/app/components/Common/TranslationActionBar.vue +139 -0
- package/app/components/Common/ViewLessMore.vue +40 -0
- package/app/components/CreateTemplateButton.vue +59 -0
- package/app/components/DynamicDataButton.vue +75 -0
- package/app/components/DynamicDataSlideover.vue +149 -0
- package/app/components/Editor/AiGenerateSlideover.vue +292 -0
- package/app/components/Editor/EditorLinkPopover.vue +154 -0
- package/app/components/Editor/RichTextEditor.vue +495 -0
- package/app/components/Email/Editor/BlockLibrary.vue +89 -0
- package/app/components/Email/Editor/BlockSettings.vue +60 -0
- package/app/components/Email/Editor/Blocks/ButtonBlock.vue +117 -0
- package/app/components/Email/Editor/Blocks/ButtonSettings.vue +228 -0
- package/app/components/Email/Editor/Blocks/DiscountCodeBlock.vue +74 -0
- package/app/components/Email/Editor/Blocks/DiscountCodeSettings.vue +162 -0
- package/app/components/Email/Editor/Blocks/DividerBlock.vue +31 -0
- package/app/components/Email/Editor/Blocks/DividerSettings.vue +108 -0
- package/app/components/Email/Editor/Blocks/FooterBlock.vue +61 -0
- package/app/components/Email/Editor/Blocks/FooterSettings.vue +123 -0
- package/app/components/Email/Editor/Blocks/GridBlock.vue +128 -0
- package/app/components/Email/Editor/Blocks/GridSettings.vue +109 -0
- package/app/components/Email/Editor/Blocks/HTMLBlock.vue +31 -0
- package/app/components/Email/Editor/Blocks/HTMLSettings.vue +85 -0
- package/app/components/Email/Editor/Blocks/HeaderBlock.vue +53 -0
- package/app/components/Email/Editor/Blocks/HeaderSettings.vue +113 -0
- package/app/components/Email/Editor/Blocks/HeadingBlock.vue +61 -0
- package/app/components/Email/Editor/Blocks/HeadingSettings.vue +156 -0
- package/app/components/Email/Editor/Blocks/ImageBlock.vue +49 -0
- package/app/components/Email/Editor/Blocks/ImageSettings.vue +53 -0
- package/app/components/Email/Editor/Blocks/IndexBlock.vue +155 -0
- package/app/components/Email/Editor/Blocks/ParagraphBlock.vue +61 -0
- package/app/components/Email/Editor/Blocks/ParagraphSettings.vue +157 -0
- package/app/components/Email/Editor/Blocks/SocialsBlock.vue +63 -0
- package/app/components/Email/Editor/Blocks/SocialsSettings.vue +117 -0
- package/app/components/Email/Editor/Blocks/SpacerBlock.vue +37 -0
- package/app/components/Email/Editor/Blocks/SpacerSettings.vue +95 -0
- package/app/components/Email/Editor/Blocks/TopProductsBlock.vue +47 -0
- package/app/components/Email/Editor/Blocks/TopProductsSettings.vue +87 -0
- package/app/components/Email/Editor/Blocks/UnsubscribeLinkBlock.vue +61 -0
- package/app/components/Email/Editor/Blocks/UnsubscribeLinkSettings.vue +123 -0
- package/app/components/Email/Editor/Canvas.vue +143 -0
- package/app/components/Email/Editor/GeneralStyleSettings.vue +125 -0
- package/app/components/Email/Editor/Index.vue +77 -0
- package/app/components/Email/Editor/LeftSidebar.vue +95 -0
- package/app/components/Email/InlinePreview.vue +124 -0
- package/app/components/Email/Preview.vue +126 -0
- package/app/components/Email/PreviewPanel.vue +57 -0
- package/app/components/Email/SendTestEmailModal.vue +121 -0
- package/app/components/Email/TranslationEditor.vue +198 -0
- package/app/components/Email/Translator/Button.vue +101 -0
- package/app/components/Email/Translator/Discount.vue +60 -0
- package/app/components/Email/Translator/Footer.vue +66 -0
- package/app/components/Email/Translator/Grid.vue +127 -0
- package/app/components/Email/Translator/Header.vue +90 -0
- package/app/components/Email/Translator/Heading.vue +66 -0
- package/app/components/Email/Translator/Image.vue +91 -0
- package/app/components/Email/Translator/Index.vue +138 -0
- package/app/components/Email/Translator/Paragraph.vue +66 -0
- package/app/components/Email/Translator/RawHtml.vue +64 -0
- package/app/components/Email/Translator/UnsubscribeLink.vue +59 -0
- package/app/components/TemplateList/BaseTable.vue +354 -0
- package/app/components/TemplateList/StatusCell.vue +67 -0
- package/app/components/Whatsapp/CategoryChanger.vue +63 -0
- package/app/components/Whatsapp/Editor/Body.vue +53 -0
- package/app/components/Whatsapp/Editor/Buttons.vue +341 -0
- package/app/components/Whatsapp/Editor/Footer.vue +70 -0
- package/app/components/Whatsapp/Editor/Header.vue +156 -0
- package/app/components/Whatsapp/Editor/Index.vue +89 -0
- package/app/components/Whatsapp/Editor/SyncWrapper.vue +166 -0
- package/app/components/Whatsapp/Preview.vue +197 -0
- package/app/components/Whatsapp/PreviewPanel.vue +24 -0
- package/app/components/Whatsapp/TranslationEditor.vue +150 -0
- package/app/components/Whatsapp/Translator/Body.vue +61 -0
- package/app/components/Whatsapp/Translator/Button.vue +209 -0
- package/app/components/Whatsapp/Translator/Footer.vue +56 -0
- package/app/components/Whatsapp/Translator/Header.vue +61 -0
- package/app/components/Whatsapp/Translator/HeaderMedia.vue +117 -0
- package/app/components/Whatsapp/Translator/Index.vue +159 -0
- package/app/composables/composerContext.ts +35 -0
- package/app/composables/useAutoTranslate.ts +242 -0
- package/app/composables/useAutoTranslateEmail.ts +294 -0
- package/app/composables/useChangeTracking.ts +212 -0
- package/app/composables/useChangeTrackingEmail.ts +227 -0
- package/app/composables/useChannels.ts +36 -0
- package/app/composables/useEmailBlockContext.ts +43 -0
- package/app/composables/useExitConfirmation.ts +39 -0
- package/app/composables/useGoBack.ts +15 -0
- package/app/composables/useIncompleteTranslations.ts +55 -0
- package/app/composables/useIncompleteTranslationsEmail.ts +56 -0
- package/app/composables/useMediaLimits.ts +42 -0
- package/app/composables/useTemplateFormValidation.ts +109 -0
- package/app/composables/useTemplateOperations.ts +147 -0
- package/app/composables/useTracking.ts +39 -0
- package/app/composables/useTranslationFilters.ts +176 -0
- package/app/composables/useTranslationFiltersEmail.ts +244 -0
- package/app/composables/useWhatsappTemplateBehaviors.ts +58 -0
- package/app/middleware/channel-guard.ts +13 -0
- package/app/pages/brands.vue +195 -0
- package/app/pages/images.vue +141 -0
- package/app/pages/index.vue +198 -0
- package/app/pages/preview/[id].vue +47 -0
- package/app/pages/template/email/[id].vue +596 -0
- package/app/pages/template/email/create.vue +562 -0
- package/app/pages/template/whatsapp/[id].vue +561 -0
- package/app/pages/template/whatsapp/create.vue +468 -0
- package/app/plugins/admin-shortcut.client.ts +7 -0
- package/app/plugins/tracking.client.ts +12 -0
- package/app/queries/ai.ts +20 -0
- package/app/queries/brands.ts +75 -0
- package/app/queries/compilation.ts +37 -0
- package/app/queries/dynamicValues.ts +13 -0
- package/app/queries/index.ts +9 -0
- package/app/queries/languages.ts +10 -0
- package/app/queries/media.ts +27 -0
- package/app/queries/messageGroups.ts +112 -0
- package/app/queries/messages.ts +42 -0
- package/app/queries/meta.ts +13 -0
- package/app/stores/adminStore.ts +50 -0
- package/app/stores/appContext.ts +99 -0
- package/app/stores/emailEditor.ts +220 -0
- package/app/stores/main.ts +3 -0
- package/app/stores/metaSubmission.ts +116 -0
- package/app/types/app-config.d.ts +14 -0
- package/app/types/hooks.d.ts +9 -0
- package/app/types/tracking.ts +148 -0
- package/app/utils/locale.ts +17 -0
- package/app/utils/notifyParent.ts +7 -0
- package/app/utils/resolveDefaultLanguage.ts +14 -0
- package/app/utils/socialDefaults.ts +42 -0
- package/app/utils/testIds.const.ts +728 -0
- package/app.config.ts +5 -0
- package/i18n/check-translations.ts +56 -0
- package/i18n/locales/de.ts +834 -0
- package/i18n/locales/en.ts +826 -0
- package/i18n/locales/es.ts +833 -0
- package/i18n/locales/fr.ts +837 -0
- package/i18n/locales/it.ts +831 -0
- package/nuxt.config.ts +65 -0
- 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> </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
|
+
}
|