@dev.smartpricing/message-composer-layer 1.0.13 → 1.0.15

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.
@@ -0,0 +1,436 @@
1
+ <script setup lang="ts">
2
+ import IncompleteTranslationsDialog from '@/components/Common/IncompleteTranslationsDialog.vue'
3
+ import { computed, ref, watch, onUnmounted, type Ref } from 'vue'
4
+ import type {
5
+ WhatsappMessage,
6
+ PostEntity,
7
+ EmailMessage,
8
+ } from '@dev.smartpricing/message-composer-utils/types'
9
+ import { useAppContextStore } from '@/stores/appContext'
10
+ import { useMetaSubmissionStore } from '@/stores/metaSubmission'
11
+ import { useMessageGroupQuery, useLanguagesQuery } from '~/queries'
12
+ import type { WaHeaderType } from '~/types/tracking'
13
+
14
+ const props = defineProps<{
15
+ mode: 'create' | 'edit'
16
+ messageGroupId?: string
17
+ }>()
18
+
19
+ const emit = defineEmits<{
20
+ saved: [data: { id: string | number; name: string; status?: string }]
21
+ cancelled: [data?: { id?: string | number }]
22
+ }>()
23
+
24
+ const store = useWhatsappComposerStore()
25
+ const { mainLanguage } = storeToRefs(useAppContextStore())
26
+ const { trackEvent } = useTracking()
27
+ const { goBackOrFallback } = useGoBack()
28
+ const { submitToMeta } = useMetaSubmissionStore()
29
+
30
+ // ── Init ──────────────────────────────────────────────────────────
31
+ if (props.mode === 'create') {
32
+ store.initCreate(mainLanguage.value)
33
+ }
34
+
35
+ // ── Edit mode: fetch message group ────────────────────────────────
36
+ const { data: messageGroupData, isPending: isFetching } =
37
+ props.mode === 'edit' && props.messageGroupId
38
+ ? useMessageGroupQuery(props.messageGroupId)
39
+ : { data: ref(null), isPending: ref(false) }
40
+
41
+ // ── Change tracking ───────────────────────────────────────────────
42
+ const changeTracking = useChangeTracking(computed(() => store.changeTrackingId))
43
+
44
+ // Record initial state for create mode
45
+ if (props.mode === 'create' && store.currentMessage) {
46
+ changeTracking.recordOriginalState(mainLanguage.value, store.currentMessage as WhatsappMessage)
47
+ }
48
+
49
+ // Watch messageGroupData to init edit mode
50
+ if (props.mode === 'edit') {
51
+ watch(
52
+ messageGroupData,
53
+ (data) => {
54
+ if (!data?.messages) return
55
+
56
+ store.initEdit(data, mainLanguage.value)
57
+
58
+ // Record original states for change tracking
59
+ for (const message of data.messages) {
60
+ if (message.render_type === 'whatsapp_v1') {
61
+ changeTracking.recordOriginalState(
62
+ message.language_id,
63
+ message as unknown as WhatsappMessage,
64
+ )
65
+ }
66
+ }
67
+ },
68
+ { immediate: true },
69
+ )
70
+ }
71
+
72
+ // ── WhatsApp template behaviors ───────────────────────────────────
73
+ const { ensureUnsubscribeButton } = useWhatsappTemplateBehaviors({
74
+ category: computed(() => store.category),
75
+ messages: computed(() => store.messages),
76
+ })
77
+
78
+ if (props.mode === 'create') {
79
+ ensureUnsubscribeButton()
80
+ }
81
+
82
+ // ── Validation ────────────────────────────────────────────────────
83
+ const { formState, draftSchema, validationErrors, validateDraft, validateFull, clearErrors } =
84
+ useTemplateFormValidation({
85
+ type: 'whatsapp',
86
+ name: computed(() => store.name),
87
+ category: computed(() => store.category),
88
+ messages: computed(() => store.messages) as Ref<
89
+ Record<string, PostEntity<EmailMessage> | WhatsappMessage>
90
+ >,
91
+ })
92
+
93
+ // ── Template operations ───────────────────────────────────────────
94
+ const successFlag = ref(false)
95
+
96
+ const { saving, createTemplate, updateTemplate } = useTemplateOperations({
97
+ onSuccess: () => {
98
+ successFlag.value = true
99
+ changeTracking.clearAllChanges()
100
+ if (props.mode === 'create') {
101
+ goBackOrFallback()
102
+ }
103
+ },
104
+ })
105
+
106
+ // ── Change detection ──────────────────────────────────────────────
107
+ const hasAnyChanges = computed(() => {
108
+ if (store.name !== store.originalName) return true
109
+ if (store.category !== store.originalCategory) return true
110
+
111
+ for (const languageId in store.messages) {
112
+ const message = store.messages[languageId]
113
+ if (message && changeTracking.hasChanges(languageId, message as WhatsappMessage)) {
114
+ return true
115
+ }
116
+ }
117
+ return false
118
+ })
119
+
120
+ useExitConfirmation(computed(() => hasAnyChanges.value && !successFlag.value))
121
+
122
+ // ── Incomplete translations ───────────────────────────────────────
123
+ const { data: languages } = useLanguagesQuery()
124
+
125
+ const { incompleteLanguages, hasIncompleteTranslations } = useIncompleteTranslations({
126
+ messages: computed(() => store.messages as Record<string, WhatsappMessage>),
127
+ languages,
128
+ })
129
+
130
+ const showIncompleteDialog = ref(false)
131
+ const pendingAction = ref<'save_draft' | 'save_as_new' | 'request_approval' | null>(null)
132
+
133
+ // ── Auto-translate ────────────────────────────────────────────────
134
+ const { translateMessage, isTranslating } = useAutoTranslate()
135
+
136
+ // ── Cleanup ───────────────────────────────────────────────────────
137
+ onUnmounted(() => {
138
+ store.$reset()
139
+ })
140
+
141
+ // ── Tracking helpers ──────────────────────────────────────────────
142
+ function track(suffix: string, data: Record<string, unknown>) {
143
+ const name = props.mode === 'create' ? `wa_create.${suffix}` : `wa_edit.${suffix}`
144
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
+ trackEvent(name as any, data as any)
146
+ }
147
+
148
+ function getWaBaseProps() {
149
+ return { name: store.name, category: store.category }
150
+ }
151
+
152
+ function getWaDraftProps() {
153
+ const resolved = resolveDefaultLanguage(store.messages, mainLanguage.value)
154
+ const msg = resolved ? (store.messages[resolved] as WhatsappMessage) : undefined
155
+ return {
156
+ ...getWaBaseProps(),
157
+ languages: Object.keys(store.messages),
158
+ header: (msg?.body?.header?.type ?? 'none') as WaHeaderType,
159
+ footer: !!msg?.body?.footer?.enabled,
160
+ buttons: msg?.body?.buttons?.buttons?.length ?? 0,
161
+ is_internal: store.isInternal,
162
+ }
163
+ }
164
+
165
+ watch(
166
+ () => store.activeTab,
167
+ (tab) => {
168
+ track('change_tab', { ...getWaBaseProps(), tab })
169
+ },
170
+ )
171
+
172
+ // Edit mode: watch for mainLanguage resolution
173
+ if (props.mode === 'edit') {
174
+ watch(
175
+ () => store.messages[mainLanguage.value],
176
+ () => {
177
+ const resolved = resolveDefaultLanguage(store.messages, mainLanguage.value)
178
+ if (resolved && resolved !== mainLanguage.value) {
179
+ store.language = resolved
180
+ store.targetLanguage = resolved
181
+ }
182
+ },
183
+ )
184
+ }
185
+
186
+ // ── Save logic ────────────────────────────────────────────────────
187
+ async function saveDraft() {
188
+ track('save_draft', getWaDraftProps())
189
+
190
+ if (props.mode === 'create') {
191
+ const result = await createTemplate(
192
+ { messageGroup: store.messageGroupData, messages: store.messages },
193
+ 'draft',
194
+ )
195
+ if (result) {
196
+ emit('saved', { id: result.id, name: result.name })
197
+ notifyParent('TEMPLATE_SAVED_DRAFT', { id: result.id, name: result.name })
198
+ }
199
+ } else {
200
+ if (!store.messageGroupId) return
201
+ await updateTemplate(store.messageGroupId, {
202
+ messageGroup: store.messageGroupUpdateData,
203
+ messages: store.messages,
204
+ })
205
+ emit('saved', { id: store.messageGroupId, name: store.name })
206
+ notifyParent('TEMPLATE_SAVED_DRAFT', { id: store.messageGroupId, name: store.name })
207
+ goBackOrFallback()
208
+ }
209
+ }
210
+
211
+ async function saveAsNew() {
212
+ if (!store.messageGroupId) return
213
+ track('save_as_new', getWaDraftProps())
214
+
215
+ const cleanedMessages: Record<string, PostEntity<WhatsappMessage>> = {}
216
+ for (const [languageId, message] of Object.entries(store.messages)) {
217
+ const { id, message_group_id, created_at, updated_at, ...templateMessage } =
218
+ message as WhatsappMessage
219
+ cleanedMessages[languageId] = {
220
+ ...templateMessage,
221
+ message_group_id: '',
222
+ metadata: {},
223
+ } as PostEntity<WhatsappMessage>
224
+ }
225
+
226
+ const result = await createTemplate(
227
+ { messageGroup: store.messageGroupUpdateData, messages: cleanedMessages },
228
+ 'draft',
229
+ )
230
+ if (result) {
231
+ emit('saved', { id: result.id, name: result.name })
232
+ notifyParent('TEMPLATE_SAVED_AS_NEW', { id: result.id, name: result.name })
233
+ navigateTo({ name: 'whatsapp-message-edit', params: { id: result.id } }, { replace: true })
234
+ }
235
+ }
236
+
237
+ async function requestApproval() {
238
+ track('request_approval', getWaDraftProps())
239
+
240
+ if (props.mode === 'create') {
241
+ const result = await createTemplate(
242
+ { messageGroup: store.messageGroupData, messages: store.messages },
243
+ 'draft',
244
+ )
245
+ if (result) {
246
+ await submitToMeta(result.id.toString())
247
+ emit('saved', { id: result.id, name: result.name })
248
+ notifyParent('TEMPLATE_APPROVAL_REQUESTED', { id: result.id, name: result.name })
249
+ }
250
+ } else {
251
+ if (!store.messageGroupId) return
252
+ await updateTemplate(store.messageGroupId, {
253
+ messageGroup: store.messageGroupUpdateData,
254
+ messages: store.messages,
255
+ })
256
+ await submitToMeta(store.messageGroupId)
257
+ emit('saved', { id: store.messageGroupId, name: store.name })
258
+ notifyParent('TEMPLATE_APPROVAL_REQUESTED', { id: store.messageGroupId, name: store.name })
259
+ goBackOrFallback()
260
+ }
261
+ }
262
+
263
+ // ── Public action handlers ────────────────────────────────────────
264
+ async function handleSaveDraft() {
265
+ if (!(await validateDraft())) return
266
+ if (hasIncompleteTranslations.value) {
267
+ pendingAction.value = 'save_draft'
268
+ showIncompleteDialog.value = true
269
+ } else {
270
+ saveDraft()
271
+ }
272
+ }
273
+
274
+ async function handleSaveAsNew() {
275
+ if (!(await validateDraft())) return
276
+ if (hasIncompleteTranslations.value) {
277
+ pendingAction.value = 'save_as_new'
278
+ showIncompleteDialog.value = true
279
+ } else {
280
+ saveAsNew()
281
+ }
282
+ }
283
+
284
+ async function handleRequestApproval() {
285
+ if (!(await validateFull())) return
286
+ if (hasIncompleteTranslations.value) {
287
+ pendingAction.value = 'request_approval'
288
+ showIncompleteDialog.value = true
289
+ } else {
290
+ requestApproval()
291
+ }
292
+ }
293
+
294
+ function handleCancel() {
295
+ const data =
296
+ props.mode === 'edit' && store.messageGroupId ? { id: store.messageGroupId } : undefined
297
+ emit('cancelled', data)
298
+ notifyParent('TEMPLATE_CANCELLED', data ? { id: data.id } : undefined)
299
+ goBackOrFallback()
300
+ }
301
+
302
+ function proceedWithSave() {
303
+ if (pendingAction.value === 'save_draft') {
304
+ saveDraft()
305
+ } else if (pendingAction.value === 'save_as_new') {
306
+ saveAsNew()
307
+ } else if (pendingAction.value === 'request_approval') {
308
+ requestApproval()
309
+ }
310
+ pendingAction.value = null
311
+ }
312
+
313
+ function goToTranslations() {
314
+ store.activeTab = 'translations'
315
+ pendingAction.value = null
316
+ }
317
+
318
+ async function translateAndSave() {
319
+ if (!incompleteLanguages.value.length) {
320
+ proceedWithSave()
321
+ return
322
+ }
323
+
324
+ const baseMessage = store.messages[store.language]
325
+ if (!baseMessage) {
326
+ proceedWithSave()
327
+ return
328
+ }
329
+
330
+ for (const { language: lang } of incompleteLanguages.value) {
331
+ const targetMsg = store.messages[lang.id]
332
+ if (!targetMsg) continue
333
+
334
+ await translateMessage(
335
+ baseMessage as WhatsappMessage,
336
+ targetMsg as WhatsappMessage,
337
+ store.language,
338
+ lang.id,
339
+ )
340
+ }
341
+
342
+ proceedWithSave()
343
+ }
344
+
345
+ // ── Tracking event forwarders ─────────────────────────────────────
346
+ function onAddLanguage(lang: string) {
347
+ track('add_language', { ...getWaBaseProps(), language: lang })
348
+ }
349
+
350
+ function onRemoveLanguage(lang: string) {
351
+ track('remove_language', { ...getWaBaseProps(), language: lang })
352
+ }
353
+
354
+ function onAutoTranslateTracked(lang: string) {
355
+ track('auto_translate', { ...getWaBaseProps(), language: lang })
356
+ }
357
+
358
+ function onPreview(lang: string) {
359
+ track('preview', { ...getWaBaseProps(), language: lang })
360
+ }
361
+
362
+ function onFiltersChanged(payload: {
363
+ language: string
364
+ search_term: string
365
+ show_only_missing: boolean
366
+ show_only_changes: boolean
367
+ }) {
368
+ track('translations_filters', { ...getWaBaseProps(), ...payload })
369
+ }
370
+
371
+ // ── Expose ────────────────────────────────────────────────────────
372
+ defineExpose({
373
+ handleSaveDraft,
374
+ handleSaveAsNew,
375
+ handleRequestApproval,
376
+ handleCancel,
377
+ saving,
378
+ isTranslating,
379
+ validationErrors,
380
+ isPending: store.isPending,
381
+ isApproved: store.isApproved,
382
+ })
383
+ </script>
384
+
385
+ <template>
386
+ <UForm
387
+ ref="templateForm"
388
+ :schema="draftSchema"
389
+ :state="formState"
390
+ :validate-on="[]"
391
+ class="flex-1 relative min-h-0 flex flex-col"
392
+ >
393
+ <div v-if="validationErrors.length">
394
+ <UAlert
395
+ color="error"
396
+ variant="subtle"
397
+ icon="i-lucide-alert-triangle"
398
+ :title="$t('editor.validation.error.title')"
399
+ :close="true"
400
+ @update:open="clearErrors()"
401
+ >
402
+ <template #description>
403
+ <ul class="list-disc pl-4 space-y-1">
404
+ <li v-for="err in validationErrors" :key="err">
405
+ {{ err }}
406
+ </li>
407
+ </ul>
408
+ </template>
409
+ </UAlert>
410
+ </div>
411
+
412
+ <ComposerWhatsappContent
413
+ v-if="store.activeTab === 'content'"
414
+ :mode="mode"
415
+ @category-changed="ensureUnsubscribeButton"
416
+ />
417
+
418
+ <ComposerWhatsappTranslations
419
+ v-if="store.activeTab === 'translations'"
420
+ :mode="mode"
421
+ @add-language="onAddLanguage"
422
+ @remove-language="onRemoveLanguage"
423
+ @auto-translate-tracked="onAutoTranslateTracked"
424
+ @preview="onPreview"
425
+ @filters-changed="onFiltersChanged"
426
+ />
427
+ </UForm>
428
+
429
+ <IncompleteTranslationsDialog
430
+ v-model="showIncompleteDialog"
431
+ :incomplete-languages="incompleteLanguages"
432
+ @save-without-translating="proceedWithSave"
433
+ @translate-and-save="translateAndSave"
434
+ @go-to-translations="goToTranslations"
435
+ />
436
+ </template>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import WhatsappEditorSyncWrapper from '@/components/Whatsapp/Editor/SyncWrapper.vue'
3
+ import WhatsappPreview from '@/components/Whatsapp/PreviewPanel.vue'
4
+ import CategoryChanger from '@/components/Whatsapp/CategoryChanger.vue'
5
+ import type { WhatsappMessage, PostEntity } from '@dev.smartpricing/message-composer-utils/types'
6
+
7
+ const props = defineProps<{
8
+ mode: 'create' | 'edit'
9
+ }>()
10
+
11
+ const emit = defineEmits<{
12
+ 'category-changed': []
13
+ }>()
14
+
15
+ const store = useWhatsappComposerStore()
16
+ const adminStore = useAdminStore()
17
+
18
+ const testIds = computed(() =>
19
+ props.mode === 'create' ? templateWhatsappCreateTestIds : templateWhatsappEditTestIds,
20
+ )
21
+ </script>
22
+
23
+ <template>
24
+ <UCard
25
+ v-if="store.currentMessage"
26
+ :ui="{
27
+ root: 'flex flex-col',
28
+ body: 'flex-1 overflow-hidden sm:px-0 px-0 py-0 sm:py-0',
29
+ header: 'sm:px-4 px-4 py-4 sm:py-4',
30
+ }"
31
+ :data-testid="testIds.contentCard"
32
+ >
33
+ <template #header>
34
+ <div class="grid grid-cols-3 gap-2">
35
+ <UFieldGroup>
36
+ <UBadge variant="outline" color="neutral" size="lg">{{
37
+ $t(`pages.whatsapp.${mode === 'create' ? 'create' : 'edit'}_message_group.name`)
38
+ }}</UBadge>
39
+ <UInput
40
+ v-model="store.name"
41
+ :placeholder="
42
+ $t(
43
+ `pages.whatsapp.${mode === 'create' ? 'create' : 'edit'}_message_group.name_placeholder`,
44
+ )
45
+ "
46
+ :data-testid="testIds.nameInput"
47
+ />
48
+ </UFieldGroup>
49
+
50
+ <UFieldGroup>
51
+ <UBadge variant="outline" color="neutral" size="lg">{{
52
+ $t(`pages.whatsapp.${mode === 'create' ? 'create' : 'edit'}_message_group.category`)
53
+ }}</UBadge>
54
+ <CategoryChanger v-model:category="store.category" @changed="emit('category-changed')" />
55
+ </UFieldGroup>
56
+
57
+ <USwitch
58
+ v-if="mode === 'create' && adminStore.isAdminModeActive"
59
+ v-model="store.isInternal"
60
+ :label="$t('common.internal')"
61
+ color="info"
62
+ :data-testid="templateWhatsappCreateTestIds.internalSwitch"
63
+ />
64
+ </div>
65
+ </template>
66
+
67
+ <div class="grid grid-cols-3 divide-x divide-muted overflow-auto max-h-full h-full">
68
+ <div class="col-span-2 overflow-y-auto pt-4 pb-4" v-if="store.currentMessage">
69
+ <h2 class="px-4 pb-4 label-lg">
70
+ {{
71
+ $t(
72
+ `pages.whatsapp.${mode === 'create' ? 'create' : 'edit'}_message_group.message_content`,
73
+ )
74
+ }}
75
+ </h2>
76
+ <WhatsappEditorSyncWrapper
77
+ v-model="store.messages[store.language] as WhatsappMessage"
78
+ :messages="store.messages as Record<string, WhatsappMessage>"
79
+ :main-language-id="store.language"
80
+ :key="store.language"
81
+ />
82
+ </div>
83
+ <div class="col-span-1 overflow-y-auto" v-if="store.currentMessage">
84
+ <WhatsappPreview :message="store.currentMessage as WhatsappMessage" />
85
+ </div>
86
+ </div>
87
+ </UCard>
88
+ </template>
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ import TranslationEditor from '@/components/Whatsapp/TranslationEditor.vue'
3
+ import LanguageList from '@/components/Common/LanguageList.vue'
4
+ import type { WhatsappMessage } from '@dev.smartpricing/message-composer-utils/types'
5
+
6
+ const props = defineProps<{
7
+ mode: 'create' | 'edit'
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ 'add-language': [lang: string]
12
+ 'remove-language': [lang: string]
13
+ 'auto-translate-tracked': [lang: string]
14
+ preview: [lang: string]
15
+ 'filters-changed': [
16
+ payload: {
17
+ language: string
18
+ search_term: string
19
+ show_only_missing: boolean
20
+ show_only_changes: boolean
21
+ },
22
+ ]
23
+ }>()
24
+
25
+ const store = useWhatsappComposerStore()
26
+
27
+ const testIds = computed(() =>
28
+ props.mode === 'create' ? templateWhatsappCreateTestIds : templateWhatsappEditTestIds,
29
+ )
30
+ </script>
31
+
32
+ <template>
33
+ <UCard
34
+ v-if="store.currentMessage"
35
+ :ui="{
36
+ root: 'flex flex-col h-full',
37
+ body: 'flex-1 overflow-hidden sm:px-0 px-0 py-0 sm:py-0',
38
+ header: 'sm:px-4 px-4 py-4 sm:py-4',
39
+ }"
40
+ :data-testid="testIds.translationsCard"
41
+ >
42
+ <div class="grid grid-cols-[auto_1fr] divide-x divide-muted overflow-auto max-h-full h-full">
43
+ <div class="overflow-y-auto">
44
+ <LanguageList
45
+ v-model:messages="store.messages"
46
+ v-model:language="store.targetLanguage"
47
+ render-type="whatsapp_v1"
48
+ :message-group-id="store.changeTrackingId"
49
+ :main-language-id="store.language"
50
+ @add-language="emit('add-language', $event)"
51
+ @remove-language="emit('remove-language', $event)"
52
+ />
53
+ </div>
54
+ <div class="overflow-y-auto" v-if="store.currentMessage && store.targetMessage">
55
+ <TranslationEditor
56
+ :base-message="
57
+ (store.messages[store.language] || store.messages['en']) as WhatsappMessage
58
+ "
59
+ v-model:target-message="store.messages[store.targetLanguage] as WhatsappMessage"
60
+ :messages="store.messages as Record<string, WhatsappMessage>"
61
+ :current-language="store.targetLanguage"
62
+ :base-language="store.language"
63
+ :message-group-id="store.changeTrackingId"
64
+ @auto-translate-tracked="emit('auto-translate-tracked', $event)"
65
+ @preview="emit('preview', $event)"
66
+ @filters-changed="emit('filters-changed', $event)"
67
+ />
68
+ </div>
69
+ </div>
70
+ </UCard>
71
+ </template>
@@ -19,7 +19,7 @@ interface UseTemplateFormValidationOptions {
19
19
  const draftSchema = z.object({
20
20
  name: z.string().min(1, 'editor.validation.error.nameRequired'),
21
21
  category: z.string().min(1),
22
- messages: z.record(z.string(), z.any()).refine((messages) => 'en' in messages, {
22
+ messages: z.record(z.string(), z.any()).refine((messages) => Object.keys(messages).length > 0, {
23
23
  message: 'editor.validation.error.messageRequired',
24
24
  }),
25
25
  })