@dev.smartpricing/message-composer-layer 4.2.11-ux.0 → 4.2.11-ux.2

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/app/api/meta.ts CHANGED
@@ -11,6 +11,15 @@ export const metaApi = {
11
11
  body: {},
12
12
  }),
13
13
 
14
+ submitMessage: (messageId: string) =>
15
+ apiClient<{ success: boolean; templateId?: string }>(
16
+ `/integrations/meta/submit-message/${messageId}`,
17
+ {
18
+ method: 'POST',
19
+ body: {},
20
+ },
21
+ ),
22
+
14
23
  getJobStatus: (jobId: string) =>
15
24
  apiClient<SubmissionJob>(`/integrations/meta/submission-jobs/${jobId}`, {
16
25
  method: 'GET',
@@ -149,6 +149,7 @@ function resetForm() {
149
149
  backgroundColor: '#F3F4F6',
150
150
  contentBackgroundColor: '#FFFFFF',
151
151
  textColor: '#000000',
152
+ linkColor: undefined,
152
153
  fontFamily: 'Arial',
153
154
  }
154
155
  defaultButtonSettings.value = {
@@ -169,7 +169,7 @@ function removeColor(index: number) {
169
169
  <!-- Default email colors -->
170
170
  <div class="space-y-2">
171
171
  <p class="text-sm font-medium">{{ $t('pages.brands.fields.default_email_colors') }}</p>
172
- <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
172
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
173
173
  <ColorInput
174
174
  :brand-colors="colors"
175
175
  v-model="defaultEmailSettings.backgroundColor"
@@ -185,6 +185,11 @@ function removeColor(index: number) {
185
185
  v-model="defaultEmailSettings.textColor"
186
186
  :label="$t('pages.brands.fields.email_text_color')"
187
187
  />
188
+ <ColorInput
189
+ :brand-colors="colors"
190
+ v-model="defaultEmailSettings.linkColor"
191
+ :label="$t('pages.brands.fields.email_link_color')"
192
+ />
188
193
  </div>
189
194
  </div>
190
195
 
@@ -57,6 +57,14 @@
57
57
  name="ph:check-circle-fill"
58
58
  class="text-success-500 w-4 h-4"
59
59
  />
60
+ <UBadge
61
+ v-if="getMessageStatus(lang.id)"
62
+ :color="statusColor(getMessageStatus(lang.id)!)"
63
+ variant="subtle"
64
+ size="sm"
65
+ >
66
+ {{ t(`status.${getMessageStatus(lang.id)}`) }}
67
+ </UBadge>
60
68
  </div>
61
69
  </div>
62
70
  <div class="flex items-center gap-2">
@@ -71,6 +79,16 @@
71
79
  }}
72
80
  </span>
73
81
  </div>
82
+ <UButton
83
+ v-if="canRequestReview(lang.id)"
84
+ size="sm"
85
+ @click.stop="emit('requestReview', lang.id)"
86
+ color="primary"
87
+ variant="ghost"
88
+ icon="ph:paper-plane-tilt"
89
+ :title="t('language_list.request_review')"
90
+ :data-testid="`${languageListTestIds.requestReviewButton}-${lang.id}`"
91
+ />
74
92
  <UButton
75
93
  size="sm"
76
94
  :disabled="grouped.used.length <= 1 || lang.id === 'en' || lang.id === mainLanguageId"
@@ -134,7 +152,12 @@ const availableLanguages = computed(() => {
134
152
  if (!languages.value) return { recommended: [], others: [] }
135
153
 
136
154
  const used = Object.keys(messages.value)
137
- const unused = languages.value.filter((lang) => !used.includes(lang.id))
155
+ let unused = languages.value.filter((lang) => !used.includes(lang.id))
156
+
157
+ // WhatsApp templates only support Meta-mapped languages; hide the rest
158
+ if (props.renderType === 'whatsapp_v1') {
159
+ unused = unused.filter((lang) => lang.meta_language_code != null)
160
+ }
138
161
 
139
162
  const recommended = unused.filter((lang) => appContext.languages.includes(lang.id))
140
163
  const others = unused.filter((lang) => !appContext.languages.includes(lang.id))
@@ -225,8 +248,39 @@ const language = defineModel<string>('language', {
225
248
  const emit = defineEmits<{
226
249
  addLanguage: [languageId: string]
227
250
  removeLanguage: [languageId: string]
251
+ requestReview: [languageId: string]
228
252
  }>()
229
253
 
254
+ // ── Per-message Meta review status (WhatsApp only) ────────────────
255
+ // Only surfaced for persisted (saved) WhatsApp messages — unsaved drafts in
256
+ // create mode / newly added languages have no Meta status to show yet.
257
+ function getMessageStatus(languageId: string): string | undefined {
258
+ const message = messages.value[languageId]
259
+ if (!message || message.render_type !== 'whatsapp_v1') return undefined
260
+ if (!('id' in message) || !message.id) return undefined
261
+ return message.status as string | undefined
262
+ }
263
+
264
+ // Mirrors the group rule, minus approved (approved is terminal here):
265
+ // a saved WhatsApp language can request review while draft or rejected.
266
+ function canRequestReview(languageId: string): boolean {
267
+ const status = getMessageStatus(languageId)
268
+ return status === 'draft' || status === 'rejected'
269
+ }
270
+
271
+ function statusColor(status: string) {
272
+ switch (status) {
273
+ case 'approved':
274
+ return 'success' as const
275
+ case 'pending':
276
+ return 'warning' as const
277
+ case 'rejected':
278
+ return 'error' as const
279
+ default:
280
+ return 'neutral' as const
281
+ }
282
+ }
283
+
230
284
  // Helper functions for change tracking
231
285
  function hasLanguageChanges(languageId: string): boolean {
232
286
  const message = messages.value[languageId]
@@ -9,6 +9,7 @@ import type {
9
9
  import { useAppContextStore } from '@/stores/appContext'
10
10
  import { useMetaSubmissionStore } from '@/stores/metaSubmission'
11
11
  import { useMessageGroupQuery, useLanguagesQuery } from '~/queries'
12
+ import { messagesApi } from '~/api/messages'
12
13
  import type { WaHeaderType } from '~/types/tracking'
13
14
 
14
15
  const props = defineProps<{
@@ -25,7 +26,9 @@ const store = useWhatsappComposerStore()
25
26
  const { mainLanguage } = storeToRefs(useAppContextStore())
26
27
  const { trackEvent } = useTracking()
27
28
  const { goBackOrFallback } = useGoBack()
28
- const { submitToMeta } = useMetaSubmissionStore()
29
+ const { submitToMeta, submitMessageToMeta } = useMetaSubmissionStore()
30
+ const { confirm } = useConfirm()
31
+ const { t } = useI18n()
29
32
 
30
33
  // ── Init ──────────────────────────────────────────────────────────
31
34
  if (props.mode === 'create') {
@@ -260,6 +263,33 @@ async function requestApproval() {
260
263
  }
261
264
  }
262
265
 
266
+ // ── Per-language review request ───────────────────────────────────
267
+ async function onRequestReview(languageId: string) {
268
+ const message = store.messages[languageId] as (WhatsappMessage & { id?: string }) | undefined
269
+ if (!message?.id) return
270
+
271
+ const languageName = languages.value?.find((l) => l.id === languageId)?.name ?? languageId
272
+
273
+ confirm({
274
+ title: t('dialogs.request_review.title'),
275
+ message: t('dialogs.request_review.message', { language: languageName }),
276
+ confirmProps: { label: t('dialogs.request_review.confirm') },
277
+ cancelProps: { label: t('common.actions.cancel') },
278
+ action: async () => {
279
+ // Decision C: persist only this language's current edits, then submit it.
280
+ await messagesApi.update(message.id!, message)
281
+ await submitMessageToMeta(message.id!)
282
+ // Reflect pending locally instead of refetching the group, so unsaved
283
+ // edits in other languages are preserved.
284
+ store.messages[languageId] = { ...message, status: 'pending' }
285
+ track('request_review_language', {
286
+ ...getWaBaseProps(),
287
+ language: languageId,
288
+ })
289
+ },
290
+ })
291
+ }
292
+
263
293
  // ── Public action handlers ────────────────────────────────────────
264
294
  async function handleSaveDraft() {
265
295
  if (!(await validateDraft())) return
@@ -420,6 +450,7 @@ defineExpose({
420
450
  :mode="mode"
421
451
  @add-language="onAddLanguage"
422
452
  @remove-language="onRemoveLanguage"
453
+ @request-review="onRequestReview"
423
454
  @auto-translate-tracked="onAutoTranslateTracked"
424
455
  @preview="onPreview"
425
456
  @filters-changed="onFiltersChanged"
@@ -10,6 +10,7 @@ const props = defineProps<{
10
10
  const emit = defineEmits<{
11
11
  'add-language': [lang: string]
12
12
  'remove-language': [lang: string]
13
+ 'request-review': [lang: string]
13
14
  'auto-translate-tracked': [lang: string]
14
15
  preview: [lang: string]
15
16
  'filters-changed': [
@@ -49,6 +50,7 @@ const testIds = computed(() =>
49
50
  :main-language-id="store.language"
50
51
  @add-language="emit('add-language', $event)"
51
52
  @remove-language="emit('remove-language', $event)"
53
+ @request-review="emit('request-review', $event)"
52
54
  />
53
55
  </div>
54
56
  <div class="overflow-y-auto" v-if="store.currentMessage && store.targetMessage">
@@ -9,6 +9,7 @@ import type {
9
9
  RefineTextResponse,
10
10
  } from '@dev.smartpricing/message-composer-utils/types'
11
11
  import {
12
+ buildLinkHTMLAttributes,
12
13
  convertToWhatsappMarkdown,
13
14
  getTipTapExtensions,
14
15
  sanitizeContent,
@@ -39,6 +40,7 @@ const props = withDefaults(
39
40
  horizontalRule?: boolean
40
41
  alignment?: boolean
41
42
  link?: boolean
43
+ linkColor?: string
42
44
  emoji?: boolean
43
45
  }>(),
44
46
  {
@@ -134,9 +136,12 @@ const starterKitConfig = computed(() => {
134
136
  link: props.link
135
137
  ? {
136
138
  openOnClick: false,
137
- HTMLAttributes: {
138
- class: 'text-primary-600 underline',
139
- },
139
+ // Callers that don't opt in with `linkColor` (e.g. ButtonSettings, whose
140
+ // links aren't part of the block-level linkColor feature) keep the old
141
+ // fixed styling instead of an unset `--link-color` custom property.
142
+ HTMLAttributes: props.linkColor
143
+ ? { class: 'underline', ...buildLinkHTMLAttributes({ mode: 'live' }) }
144
+ : { class: 'text-primary-600 underline' },
140
145
  }
141
146
  : false,
142
147
  heading: false,
@@ -373,7 +378,7 @@ defineExpose({
373
378
  </script>
374
379
 
375
380
  <template>
376
- <div :data-testid="richTextEditorTestIds.root">
381
+ <div :data-testid="richTextEditorTestIds.root" :style="{ '--link-color': linkColor }">
377
382
  <div class="rounded-md border border-accented overflow-hidden">
378
383
  <UEditor
379
384
  ref="editorRef"
@@ -18,6 +18,7 @@ const editor = useEditor({
18
18
  extensions: getTipTapExtensions({
19
19
  preset: 'email',
20
20
  dynamicValues: true,
21
+ linkColor: { mode: 'live' },
21
22
  }),
22
23
  })
23
24
 
@@ -47,6 +48,7 @@ defineOptions({
47
48
  fontFamily: block.settings.fontFamily ?? emailEditorStore.resolvedSettings.fontFamily,
48
49
  fontSize: `${block.settings.fontSize}px`,
49
50
  color: block.settings.textColor ?? emailEditorStore.resolvedSettings.textColor,
51
+ '--link-color': block.settings.linkColor ?? emailEditorStore.resolvedSettings.linkColor,
50
52
  }"
51
53
  >
52
54
  <EditorContent :editor="editor" class="pointer-events-none" />
@@ -23,6 +23,19 @@ const content = computed({
23
23
  },
24
24
  })
25
25
 
26
+ const resolvedLinkColor = computed(
27
+ () => props.block.settings.linkColor ?? emailEditorStore.resolvedSettings.linkColor,
28
+ )
29
+
30
+ function matchTextColor() {
31
+ emailEditorStore.updateBlock(props.block.id, {
32
+ settings: {
33
+ ...props.block.settings,
34
+ linkColor: props.block.settings.textColor ?? emailEditorStore.resolvedSettings.textColor,
35
+ },
36
+ })
37
+ }
38
+
26
39
  function resetTextStyle() {
27
40
  emailEditorStore.updateBlock(props.block.id, {
28
41
  settings: {
@@ -38,6 +51,7 @@ function resetColors() {
38
51
  settings: {
39
52
  ...props.block.settings,
40
53
  textColor: DEFAULT_FOOTER_SETTINGS.textColor,
54
+ linkColor: DEFAULT_FOOTER_SETTINGS.linkColor,
41
55
  backgroundColor: DEFAULT_FOOTER_SETTINGS.backgroundColor,
42
56
  },
43
57
  })
@@ -63,6 +77,7 @@ defineOptions({
63
77
  underline
64
78
  alignment
65
79
  link
80
+ :link-color="resolvedLinkColor"
66
81
  emoji
67
82
  dynamic-values
68
83
  />
@@ -113,6 +128,25 @@ defineOptions({
113
128
  :default-value="emailEditorStore.resolvedSettings.textColor"
114
129
  />
115
130
 
131
+ <!-- Link Color -->
132
+ <ColorInput
133
+ v-model="block.settings.linkColor"
134
+ :label="$t('editor.email.blocks.heading.settings.link')"
135
+ :default-value="emailEditorStore.resolvedSettings.linkColor"
136
+ :data-testid="footerSettingsTestIds.linkColorInput"
137
+ >
138
+ <template #trailing>
139
+ <UButton
140
+ size="xs"
141
+ color="neutral"
142
+ variant="ghost"
143
+ :label="$t('editor.email.blocks.heading.settings.match_text_color')"
144
+ :data-testid="footerSettingsTestIds.matchTextColorButton"
145
+ @click.stop="matchTextColor"
146
+ />
147
+ </template>
148
+ </ColorInput>
149
+
116
150
  <!-- Background Color -->
117
151
  <ColorInput
118
152
  v-model="block.settings.backgroundColor"
@@ -18,6 +18,7 @@ const editor = useEditor({
18
18
  extensions: getTipTapExtensions({
19
19
  preset: 'email',
20
20
  dynamicValues: true,
21
+ linkColor: { mode: 'live' },
21
22
  }),
22
23
  })
23
24
 
@@ -47,6 +48,7 @@ defineOptions({
47
48
  fontFamily: block.settings.fontFamily ?? emailEditorStore.resolvedSettings.fontFamily,
48
49
  fontSize: `${block.settings.fontSize}px`,
49
50
  color: block.settings.textColor ?? emailEditorStore.resolvedSettings.textColor,
51
+ '--link-color': block.settings.linkColor ?? emailEditorStore.resolvedSettings.linkColor,
50
52
  }"
51
53
  >
52
54
  <EditorContent :editor="editor" class="pointer-events-none" />
@@ -30,6 +30,19 @@ const content = computed({
30
30
  },
31
31
  })
32
32
 
33
+ const resolvedLinkColor = computed(
34
+ () => props.block.settings.linkColor ?? emailEditorStore.resolvedSettings.linkColor,
35
+ )
36
+
37
+ function matchTextColor() {
38
+ emailEditorStore.updateBlock(props.block.id, {
39
+ settings: {
40
+ ...props.block.settings,
41
+ linkColor: props.block.settings.textColor ?? emailEditorStore.resolvedSettings.textColor,
42
+ },
43
+ })
44
+ }
45
+
33
46
  const typeOptions = HEADING_TAGS.slice(0, 3).map((heading) => ({
34
47
  value: heading,
35
48
  label: t(`editor.email.blocks.text_types.${heading}`),
@@ -51,6 +64,7 @@ function resetColors() {
51
64
  settings: {
52
65
  ...props.block.settings,
53
66
  textColor: DEFAULT_HEADING_SETTINGS.textColor,
67
+ linkColor: DEFAULT_HEADING_SETTINGS.linkColor,
54
68
  backgroundColor: DEFAULT_HEADING_SETTINGS.backgroundColor,
55
69
  },
56
70
  })
@@ -83,6 +97,7 @@ defineOptions({
83
97
  underline
84
98
  alignment
85
99
  link
100
+ :link-color="resolvedLinkColor"
86
101
  emoji
87
102
  improve
88
103
  generate
@@ -146,6 +161,25 @@ defineOptions({
146
161
  :default-value="emailEditorStore.resolvedSettings.textColor"
147
162
  />
148
163
 
164
+ <!-- Link Color -->
165
+ <ColorInput
166
+ v-model="block.settings.linkColor"
167
+ :label="$t('editor.email.blocks.heading.settings.link')"
168
+ :default-value="emailEditorStore.resolvedSettings.linkColor"
169
+ :data-testid="headingSettingsTestIds.linkColorInput"
170
+ >
171
+ <template #trailing>
172
+ <UButton
173
+ size="xs"
174
+ color="neutral"
175
+ variant="ghost"
176
+ :label="$t('editor.email.blocks.heading.settings.match_text_color')"
177
+ :data-testid="headingSettingsTestIds.matchTextColorButton"
178
+ @click.stop="matchTextColor"
179
+ />
180
+ </template>
181
+ </ColorInput>
182
+
149
183
  <!-- Background Color -->
150
184
  <ColorInput
151
185
  v-model="block.settings.backgroundColor"
@@ -18,6 +18,7 @@ const editor = useEditor({
18
18
  extensions: getTipTapExtensions({
19
19
  preset: 'email',
20
20
  dynamicValues: true,
21
+ linkColor: { mode: 'live' },
21
22
  }),
22
23
  })
23
24
 
@@ -47,6 +48,7 @@ defineOptions({
47
48
  fontFamily: block.settings.fontFamily ?? emailEditorStore.resolvedSettings.fontFamily,
48
49
  fontSize: `${block.settings.fontSize}px`,
49
50
  color: block.settings.textColor ?? emailEditorStore.resolvedSettings.textColor,
51
+ '--link-color': block.settings.linkColor ?? emailEditorStore.resolvedSettings.linkColor,
50
52
  }"
51
53
  >
52
54
  <EditorContent :editor="editor" class="pointer-events-none" />
@@ -30,6 +30,19 @@ const content = computed({
30
30
  },
31
31
  })
32
32
 
33
+ const resolvedLinkColor = computed(
34
+ () => props.block.settings.linkColor ?? emailEditorStore.resolvedSettings.linkColor,
35
+ )
36
+
37
+ function matchTextColor() {
38
+ emailEditorStore.updateBlock(props.block.id, {
39
+ settings: {
40
+ ...props.block.settings,
41
+ linkColor: props.block.settings.textColor ?? emailEditorStore.resolvedSettings.textColor,
42
+ },
43
+ })
44
+ }
45
+
33
46
  const typeOptions = TEXT_TAGS.map((tag) => ({
34
47
  value: tag,
35
48
  label: t(`editor.email.blocks.text_types.${tag}`),
@@ -51,6 +64,7 @@ function resetColors() {
51
64
  settings: {
52
65
  ...props.block.settings,
53
66
  textColor: DEFAULT_PARAGRAPH_SETTINGS.textColor,
67
+ linkColor: DEFAULT_PARAGRAPH_SETTINGS.linkColor,
54
68
  backgroundColor: DEFAULT_PARAGRAPH_SETTINGS.backgroundColor,
55
69
  },
56
70
  })
@@ -85,6 +99,7 @@ defineOptions({
85
99
  horizontal-rule
86
100
  alignment
87
101
  link
102
+ :link-color="resolvedLinkColor"
88
103
  emoji
89
104
  improve
90
105
  generate
@@ -147,6 +162,25 @@ defineOptions({
147
162
  :default-value="emailEditorStore.resolvedSettings.textColor"
148
163
  />
149
164
 
165
+ <!-- Link Color -->
166
+ <ColorInput
167
+ v-model="block.settings.linkColor"
168
+ :label="$t('editor.email.blocks.heading.settings.link')"
169
+ :default-value="emailEditorStore.resolvedSettings.linkColor"
170
+ :data-testid="paragraphSettingsTestIds.linkColorInput"
171
+ >
172
+ <template #trailing>
173
+ <UButton
174
+ size="xs"
175
+ color="neutral"
176
+ variant="ghost"
177
+ :label="$t('editor.email.blocks.heading.settings.match_text_color')"
178
+ :data-testid="paragraphSettingsTestIds.matchTextColorButton"
179
+ @click.stop="matchTextColor"
180
+ />
181
+ </template>
182
+ </ColorInput>
183
+
150
184
  <!-- Background Color -->
151
185
  <ColorInput
152
186
  v-model="block.settings.backgroundColor"
@@ -17,6 +17,7 @@ const editor = useEditor({
17
17
  extensions: getTipTapExtensions({
18
18
  preset: 'email',
19
19
  dynamicValues: true,
20
+ linkColor: { mode: 'live' },
20
21
  }),
21
22
  })
22
23
 
@@ -45,6 +46,7 @@ defineOptions({
45
46
  fontFamily: block.settings.fontFamily ?? emailEditorStore.resolvedSettings.fontFamily,
46
47
  fontSize: `${block.settings.fontSize}px`,
47
48
  color: block.settings.textColor ?? emailEditorStore.resolvedSettings.textColor,
49
+ '--link-color': block.settings.linkColor ?? emailEditorStore.resolvedSettings.linkColor,
48
50
  textAlign: block.settings.alignment,
49
51
  }"
50
52
  >
@@ -24,6 +24,19 @@ const content = computed({
24
24
  },
25
25
  })
26
26
 
27
+ const resolvedLinkColor = computed(
28
+ () => props.block.settings.linkColor ?? emailEditorStore.resolvedSettings.linkColor,
29
+ )
30
+
31
+ function matchTextColor() {
32
+ emailEditorStore.updateBlock(props.block.id, {
33
+ settings: {
34
+ ...props.block.settings,
35
+ linkColor: props.block.settings.textColor ?? emailEditorStore.resolvedSettings.textColor,
36
+ },
37
+ })
38
+ }
39
+
27
40
  function resetTextStyle() {
28
41
  emailEditorStore.updateBlock(props.block.id, {
29
42
  settings: {
@@ -39,6 +52,7 @@ function resetColors() {
39
52
  settings: {
40
53
  ...props.block.settings,
41
54
  textColor: DEFAULT_UNSUBSCRIBE_LINK_SETTINGS.textColor,
55
+ linkColor: DEFAULT_UNSUBSCRIBE_LINK_SETTINGS.linkColor,
42
56
  backgroundColor: DEFAULT_UNSUBSCRIBE_LINK_SETTINGS.backgroundColor,
43
57
  },
44
58
  })
@@ -63,6 +77,7 @@ defineOptions({
63
77
  italic
64
78
  underline
65
79
  link
80
+ :link-color="resolvedLinkColor"
66
81
  dynamic-values
67
82
  :data-testid="unsubscribeLinkSettingsTestIds.contentEditor"
68
83
  />
@@ -113,6 +128,24 @@ defineOptions({
113
128
  :data-testid="unsubscribeLinkSettingsTestIds.textColorInput"
114
129
  />
115
130
 
131
+ <ColorInput
132
+ v-model="block.settings.linkColor"
133
+ :label="$t('editor.email.blocks.heading.settings.link')"
134
+ :default-value="emailEditorStore.resolvedSettings.linkColor"
135
+ :data-testid="unsubscribeLinkSettingsTestIds.linkColorInput"
136
+ >
137
+ <template #trailing>
138
+ <UButton
139
+ size="xs"
140
+ color="neutral"
141
+ variant="ghost"
142
+ :label="$t('editor.email.blocks.heading.settings.match_text_color')"
143
+ :data-testid="unsubscribeLinkSettingsTestIds.matchTextColorButton"
144
+ @click.stop="matchTextColor"
145
+ />
146
+ </template>
147
+ </ColorInput>
148
+
116
149
  <ColorInput
117
150
  v-model="block.settings.backgroundColor"
118
151
  :label="$t('editor.email.blocks.heading.settings.backgroundBlock')"
@@ -87,9 +87,7 @@ const emailEditorStore = useEmailEditorStore()
87
87
  const composerContext = useComposerContext()
88
88
  const { mutate: sendTest, isLoading: isSending, status } = useSendTestEmailMutation()
89
89
 
90
- const defaultSenderEmail = computed(
91
- () => composerContext.organizationData?.value?.senderEmail ?? '',
92
- )
90
+ const senderEmails = computed(() => composerContext.organizationData?.value?.senderEmails ?? [])
93
91
 
94
92
  const accommodationName = computed(
95
93
  () => composerContext.organizationData?.value?.accommodationName ?? '',
@@ -102,10 +100,11 @@ const state = ref<Schema>({
102
100
  })
103
101
 
104
102
  watch(
105
- defaultSenderEmail,
106
- (email) => {
107
- if (!state.value.from) {
108
- state.value.from = email
103
+ senderEmails,
104
+ (emails) => {
105
+ const [firstEmail] = emails
106
+ if (!state.value.from && firstEmail) {
107
+ state.value.from = firstEmail
109
108
  }
110
109
  },
111
110
  { immediate: true },
@@ -136,9 +135,7 @@ const schema = z.object({
136
135
  { message: t('editor.validation.error.emailInvalid') },
137
136
  ),
138
137
  sender_name: z.string().optional(),
139
- from: z.string().refine((value) => !value || isEmail(value), {
140
- message: t('editor.validation.error.emailInvalid'),
141
- }),
138
+ from: z.string().min(1, t('editor.validation.error.emailRequired')),
142
139
  })
143
140
 
144
141
  type Schema = z.output<typeof schema>
@@ -217,11 +214,16 @@ watch(status, (newStatus) => {
217
214
  :data-testid="sendTestEmailModalTestIds.toInput"
218
215
  />
219
216
  </UFormField>
220
- <UFormField name="from" :label="$t('pages.email.test_email_from')">
221
- <UInput
217
+ <UFormField
218
+ name="from"
219
+ :label="$t('pages.email.test_email_from')"
220
+ :data-testid="sendTestEmailModalTestIds.fromFormField"
221
+ >
222
+ <USelectMenu
222
223
  v-model="state.from"
223
- type="email"
224
+ :items="senderEmails"
224
225
  :placeholder="$t('pages.email.test_email_from_placeholder')"
226
+ :data-testid="sendTestEmailModalTestIds.fromSelect"
225
227
  />
226
228
  </UFormField>
227
229
  <UFormField
@@ -2,7 +2,8 @@ import type { InjectionKey, Ref } from 'vue'
2
2
  import type { AppContextConfig } from '@dev.smartpricing/message-composer-utils/types'
3
3
 
4
4
  export interface OrganizationData {
5
- senderEmail: string
5
+ /** Verified sender addresses available for the test-email "from" field, default first. */
6
+ senderEmails: string[]
6
7
  /** Tenant accommodation/property name — defaults the test-email sender name */
7
8
  accommodationName?: string
8
9
  }
@@ -35,6 +35,31 @@ export const useMetaSubmissionStore = () => {
35
35
  }
36
36
  }
37
37
 
38
+ // Submit a single message (one language) for Meta review. Synchronous:
39
+ // the endpoint submits inline and flips the message to `pending`, so there
40
+ // is no job to poll — unlike the group flow.
41
+ const submitMessageToMeta = async (messageId: string) => {
42
+ if (!isConfigured.value) {
43
+ throw new Error('Meta configuration not available')
44
+ }
45
+
46
+ try {
47
+ const response = await metaApi.submitMessage(messageId)
48
+ toast.add({
49
+ color: 'success',
50
+ title: t('meta_submission.message_success'),
51
+ })
52
+ return response
53
+ } catch (error) {
54
+ console.error('Failed to submit message to Meta:', error)
55
+ toast.add({
56
+ color: 'error',
57
+ title: t('meta_submission.message_error'),
58
+ })
59
+ throw error
60
+ }
61
+ }
62
+
38
63
  const startBackgroundPolling = (jobId: string, messageGroupId: string) => {
39
64
  // Add job to active jobs
40
65
  activeJobs.value.set(jobId, {
@@ -110,6 +135,7 @@ export const useMetaSubmissionStore = () => {
110
135
  hasActiveJobs,
111
136
  isConfigured,
112
137
  submitToMeta,
138
+ submitMessageToMeta,
113
139
  startBackgroundPolling,
114
140
  stopPolling,
115
141
  }
@@ -105,6 +105,7 @@ export interface TrackingEventMap {
105
105
  // === Whatsapp_edit ===
106
106
  'wa_edit.save_as_new': WaDraftProperties
107
107
  'wa_edit.request_approval': WaDraftProperties
108
+ 'wa_edit.request_review_language': WaLanguageProperties
108
109
  'wa_edit.add_language': WaLanguageProperties
109
110
  'wa_edit.remove_language': WaLanguageProperties
110
111
  'wa_edit.auto_translate': WaLanguageProperties
@@ -122,6 +122,7 @@ export const languageListTestIds = {
122
122
  commandPalette: 'language-list-command-palette',
123
123
  languageItem: 'language-list-item',
124
124
  deleteButton: 'language-list-delete-button',
125
+ requestReviewButton: 'language-list-request-review-button',
125
126
  } as const
126
127
 
127
128
  export const loadingOverlayTestIds = {
@@ -330,6 +331,8 @@ export const sendTestEmailModalTestIds = {
330
331
  triggerButton: 'send-test-email-modal-trigger-button',
331
332
  toFormField: 'send-test-email-modal-to-form-field',
332
333
  toInput: 'send-test-email-modal-to-input',
334
+ fromFormField: 'send-test-email-modal-from-form-field',
335
+ fromSelect: 'send-test-email-modal-from-select',
333
336
  senderNameFormField: 'send-test-email-modal-sender-name-form-field',
334
337
  senderNameInput: 'send-test-email-modal-sender-name-input',
335
338
  cancelButton: 'send-test-email-modal-cancel-button',
@@ -614,6 +617,8 @@ export const footerSettingsTestIds = {
614
617
  container: 'footer-settings-container',
615
618
  fontSizeField: 'footer-settings-font-size-field',
616
619
  fontSizeInput: 'footer-settings-font-size-input',
620
+ linkColorInput: 'footer-settings-link-color-input',
621
+ matchTextColorButton: 'footer-settings-match-text-color-button',
617
622
  } as const
618
623
 
619
624
  export const gridBlockTestIds = {
@@ -652,6 +657,8 @@ export const headingSettingsTestIds = {
652
657
  tagSelect: 'heading-settings-tag-select',
653
658
  fontSizeField: 'heading-settings-font-size-field',
654
659
  fontSizeInput: 'heading-settings-font-size-input',
660
+ linkColorInput: 'heading-settings-link-color-input',
661
+ matchTextColorButton: 'heading-settings-match-text-color-button',
655
662
  } as const
656
663
 
657
664
  export const htmlBlockTestIds = {
@@ -690,6 +697,8 @@ export const paragraphSettingsTestIds = {
690
697
  tagSelect: 'paragraph-settings-tag-select',
691
698
  fontSizeField: 'paragraph-settings-font-size-field',
692
699
  fontSizeInput: 'paragraph-settings-font-size-input',
700
+ linkColorInput: 'paragraph-settings-link-color-input',
701
+ matchTextColorButton: 'paragraph-settings-match-text-color-button',
693
702
  } as const
694
703
 
695
704
  export const socialsBlockTestIds = {
@@ -731,6 +740,8 @@ export const unsubscribeLinkSettingsTestIds = {
731
740
  fontSizeInput: 'unsubscribe-link-settings-font-size-input',
732
741
  textColorInput: 'unsubscribe-link-settings-text-color-input',
733
742
  backgroundColorInput: 'unsubscribe-link-settings-background-color-input',
743
+ linkColorInput: 'unsubscribe-link-settings-link-color-input',
744
+ matchTextColorButton: 'unsubscribe-link-settings-match-text-color-button',
734
745
  } as const
735
746
 
736
747
  export const previewByIdTestIds = {
@@ -101,6 +101,12 @@ export default {
101
101
  success: '„{name}“ wurde veröffentlicht.',
102
102
  title: 'Nachricht veröffentlichen',
103
103
  },
104
+ request_review: {
105
+ confirm: 'Überprüfung anfordern',
106
+ message:
107
+ 'Die {language}-Übersetzung zur Überprüfung einreichen? Sie können sie nicht bearbeiten, solange sie auf Genehmigung wartet.',
108
+ title: 'Überprüfung anfordern',
109
+ },
104
110
  unpublish_message_group: {
105
111
  message:
106
112
  'Veröffentlichung von „{name}“ aufheben? Es ist dann nicht mehr für Kundinnen und Kunden sichtbar.',
@@ -260,6 +266,8 @@ export default {
260
266
  color: 'Farbe',
261
267
  content: 'Inhalt',
262
268
  font: 'Schriftart',
269
+ link: 'Link',
270
+ match_text_color: 'An Textfarbe anpassen',
263
271
  size: 'Größe',
264
272
  text: 'Text',
265
273
  textStyle: 'Textstil',
@@ -613,6 +621,7 @@ export default {
613
621
  change: 'Änderung',
614
622
  changes: 'Änderungen',
615
623
  changes_count: '{count} Änderungen',
624
+ request_review: 'Überprüfung anfordern',
616
625
  translated_percentage: '{percentage}% übersetzt',
617
626
  unsaved_changes: 'Diese Übersetzung hat ungespeicherte Änderungen',
618
627
  },
@@ -625,6 +634,8 @@ export default {
625
634
  meta_submission: {
626
635
  error: 'Meta-Übermittlung mit Fehlern abgeschlossen',
627
636
  failed_count: 'Fehlgeschlagene Nachrichten: {count}',
637
+ message_error: 'Übersetzung konnte nicht zur Überprüfung eingereicht werden',
638
+ message_success: 'Übersetzung zur Überprüfung eingereicht',
628
639
  success: 'Nachrichten an Meta übermittelt',
629
640
  },
630
641
  modals: {
@@ -684,6 +695,7 @@ export default {
684
695
  default_font: 'Standardschriftart',
685
696
  email_bg_color: 'Hintergrund',
686
697
  email_content_bg_color: 'Inhalt-Hintergrund',
698
+ email_link_color: 'Link-Farbe',
687
699
  email_text_color: 'Text',
688
700
  extracting_colors: 'Farben werden aus dem Logo extrahiert...',
689
701
  logo: 'Logo',
@@ -97,6 +97,12 @@ export default {
97
97
  success: '"{name}" has been published.',
98
98
  title: 'Publish message',
99
99
  },
100
+ request_review: {
101
+ confirm: 'Request review',
102
+ message:
103
+ "Submit the {language} translation for review? You won't be able to edit it while it's pending approval.",
104
+ title: 'Request review',
105
+ },
100
106
  unpublish_message_group: {
101
107
  message: 'Unpublish "{name}"? It will no longer be visible to clients.',
102
108
  success: '"{name}" has been unpublished.',
@@ -254,6 +260,8 @@ export default {
254
260
  color: 'Color',
255
261
  content: 'Content',
256
262
  font: 'Font',
263
+ link: 'Link',
264
+ match_text_color: 'Match text color',
257
265
  size: 'Size',
258
266
  text: 'Text',
259
267
  textStyle: 'Text style',
@@ -603,6 +611,7 @@ export default {
603
611
  change: 'change',
604
612
  changes: 'changes',
605
613
  changes_count: '{count} changes',
614
+ request_review: 'Request review',
606
615
  translated_percentage: '{percentage}% translated',
607
616
  unsaved_changes: 'This translation has unsaved changes',
608
617
  },
@@ -615,6 +624,8 @@ export default {
615
624
  meta_submission: {
616
625
  error: 'Meta submission completed with errors',
617
626
  failed_count: 'Failed messages: {count}',
627
+ message_error: 'Failed to submit translation for review',
628
+ message_success: 'Translation submitted for review',
618
629
  success: 'Messages submitted to Meta',
619
630
  },
620
631
  modals: {
@@ -673,6 +684,7 @@ export default {
673
684
  default_font: 'Default font',
674
685
  email_bg_color: 'Background',
675
686
  email_content_bg_color: 'Content background',
687
+ email_link_color: 'Link color',
676
688
  email_text_color: 'Text',
677
689
  extracting_colors: 'Extracting colors from logo...',
678
690
  logo: 'Logo',
@@ -98,6 +98,12 @@ export default {
98
98
  success: '"{name}" se ha publicado.',
99
99
  title: 'Publicar mensaje',
100
100
  },
101
+ request_review: {
102
+ confirm: 'Solicitar revisión',
103
+ message:
104
+ '¿Enviar la traducción en {language} para revisión? No podrá editarla mientras esté pendiente de aprobación.',
105
+ title: 'Solicitar revisión',
106
+ },
101
107
  unpublish_message_group: {
102
108
  message: '¿Despublicar "{name}"? Ya no será visible para los clientes.',
103
109
  success: '"{name}" se ha despublicado.',
@@ -255,6 +261,8 @@ export default {
255
261
  color: 'Color',
256
262
  content: 'Contenido',
257
263
  font: 'Fuente',
264
+ link: 'Enlace',
265
+ match_text_color: 'Igualar al color del texto',
258
266
  size: 'Tamaño',
259
267
  text: 'Texto',
260
268
  textStyle: 'Estilo del texto',
@@ -611,6 +619,7 @@ export default {
611
619
  change: 'cambio',
612
620
  changes: 'cambios',
613
621
  changes_count: '{count} cambios',
622
+ request_review: 'Solicitar revisión',
614
623
  translated_percentage: '{percentage}% traducido',
615
624
  unsaved_changes: 'Esta traducción tiene cambios sin guardar',
616
625
  },
@@ -623,6 +632,8 @@ export default {
623
632
  meta_submission: {
624
633
  error: 'Envío a Meta completado con errores',
625
634
  failed_count: 'Mensajes fallidos: {count}',
635
+ message_error: 'No se pudo enviar la traducción para revisión',
636
+ message_success: 'Traducción enviada para revisión',
626
637
  success: 'Mensajes enviados a Meta',
627
638
  },
628
639
  modals: {
@@ -682,6 +693,7 @@ export default {
682
693
  default_font: 'Fuente predeterminada',
683
694
  email_bg_color: 'Fondo',
684
695
  email_content_bg_color: 'Fondo contenido',
696
+ email_link_color: 'Color de enlace',
685
697
  email_text_color: 'Texto',
686
698
  extracting_colors: 'Extrayendo colores del logo...',
687
699
  logo: 'Logo',
@@ -98,6 +98,12 @@ export default {
98
98
  success: '« {name} » a été publié.',
99
99
  title: 'Publier le message',
100
100
  },
101
+ request_review: {
102
+ confirm: 'Demander une révision',
103
+ message:
104
+ 'Envoyer la traduction {language} pour révision ? Vous ne pourrez pas la modifier tant qu’elle est en attente d’approbation.',
105
+ title: 'Demander une révision',
106
+ },
101
107
  unpublish_message_group: {
102
108
  message: 'Dépublier « {name} » ? Il ne sera plus visible aux clients.',
103
109
  success: '« {name} » a été dépublié.',
@@ -256,6 +262,8 @@ export default {
256
262
  color: 'Couleur',
257
263
  content: 'Contenu',
258
264
  font: 'Police',
265
+ link: 'Lien',
266
+ match_text_color: 'Assortir à la couleur du texte',
259
267
  size: 'Taille',
260
268
  text: 'Texte',
261
269
  textStyle: 'Style de texte',
@@ -615,6 +623,7 @@ export default {
615
623
  change: 'modification',
616
624
  changes: 'modifications',
617
625
  changes_count: '{count} modifications',
626
+ request_review: 'Demander une révision',
618
627
  translated_percentage: '{percentage}% traduit',
619
628
  unsaved_changes: 'Cette traduction contient des modifications non enregistrées',
620
629
  },
@@ -627,6 +636,8 @@ export default {
627
636
  meta_submission: {
628
637
  error: 'Soumission à Meta terminée avec des erreurs',
629
638
  failed_count: 'Messages en échec : {count}',
639
+ message_error: 'Échec de l’envoi de la traduction pour révision',
640
+ message_success: 'Traduction envoyée pour révision',
630
641
  success: 'Messages soumis à Meta',
631
642
  },
632
643
  modals: {
@@ -686,6 +697,7 @@ export default {
686
697
  default_font: 'Police par défaut',
687
698
  email_bg_color: 'Fond',
688
699
  email_content_bg_color: 'Fond contenu',
700
+ email_link_color: 'Couleur du lien',
689
701
  email_text_color: 'Texte',
690
702
  extracting_colors: 'Extraction des couleurs du logo...',
691
703
  logo: 'Logo',
@@ -97,6 +97,12 @@ export default {
97
97
  success: '"{name}" è stato pubblicato.',
98
98
  title: 'Pubblica messaggio',
99
99
  },
100
+ request_review: {
101
+ confirm: 'Richiedi revisione',
102
+ message:
103
+ 'Inviare per la revisione la traduzione in {language}? Non potrai modificarla finché è in attesa di approvazione.',
104
+ title: 'Richiedi revisione',
105
+ },
100
106
  unpublish_message_group: {
101
107
  message: 'Annullare la pubblicazione di "{name}"? Non sarà più visibile ai clienti.',
102
108
  success: 'La pubblicazione di "{name}" è stata annullata.',
@@ -255,6 +261,8 @@ export default {
255
261
  color: 'Colore',
256
262
  content: 'Contenuto',
257
263
  font: 'Font',
264
+ link: 'Link',
265
+ match_text_color: 'Abbina al colore del testo',
258
266
  size: 'Dimensione',
259
267
  text: 'Testo',
260
268
  textStyle: 'Stile testo',
@@ -608,6 +616,7 @@ export default {
608
616
  change: 'modifica',
609
617
  changes: 'modifiche',
610
618
  changes_count: '{count} modifiche',
619
+ request_review: 'Richiedi revisione',
611
620
  translated_percentage: '{percentage}% tradotto',
612
621
  unsaved_changes: 'Questa traduzione ha modifiche non salvate',
613
622
  },
@@ -620,6 +629,8 @@ export default {
620
629
  meta_submission: {
621
630
  error: 'Invio a Meta completato con errori',
622
631
  failed_count: 'Messaggi non riusciti: {count}',
632
+ message_error: 'Impossibile inviare la traduzione per la revisione',
633
+ message_success: 'Traduzione inviata per la revisione',
623
634
  success: 'Messaggi inviati a Meta',
624
635
  },
625
636
  modals: {
@@ -679,6 +690,7 @@ export default {
679
690
  default_font: 'Font predefinito',
680
691
  email_bg_color: 'Sfondo',
681
692
  email_content_bg_color: 'Sfondo contenuto',
693
+ email_link_color: 'Colore link',
682
694
  email_text_color: 'Testo',
683
695
  extracting_colors: 'Estrazione dei colori dal logo...',
684
696
  logo: 'Logo',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.smartpricing/message-composer-layer",
3
- "version": "4.2.11-ux.0",
3
+ "version": "4.2.11-ux.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -36,7 +36,7 @@
36
36
  "vue-router": "^5.0.7",
37
37
  "vue3-emoji-picker": "^1.1.8",
38
38
  "zod": "^4.4.3",
39
- "@dev.smartpricing/message-composer-utils": "4.2.11-ux.0"
39
+ "@dev.smartpricing/message-composer-utils": "4.2.11-ux.2"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@nuxt/eslint": "^1.15.2",