@dev.smartpricing/message-composer-layer 4.2.10 → 4.2.11-ux.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/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]
@@ -1,17 +1,29 @@
1
1
  <script setup lang="ts">
2
- import type { RenderType } from '@dev.smartpricing/message-composer-utils/types'
2
+ import type { RenderType, MessageStatus } from '@dev.smartpricing/message-composer-utils/types'
3
3
  import { useMessageGroupsQuery } from '~/queries/messageGroups'
4
4
 
5
- defineOptions({ name: 'TemplateSelect' })
5
+ defineOptions({ name: 'MessageGroupSelect' })
6
6
 
7
7
  const props = withDefaults(
8
8
  defineProps<{
9
9
  renderType?: RenderType
10
10
  placeholder?: string
11
11
  searchPlaceholder?: string
12
+ /**
13
+ * Map of message-group id -> reason text explaining why the group is not
14
+ * selectable (e.g. already linked to another newsletter by an external
15
+ * system). Presence of a key disables the group; the value is shown verbatim
16
+ * as the tooltip, so it must already be localized by the caller.
17
+ *
18
+ * NOTE: the caller is responsible for excluding the currently-selected group
19
+ * when editing, otherwise the current selection will render as disabled.
20
+ */
21
+ disabledGroupReasons?: Record<string, string>
22
+ /** Overrides the "hide inactive" filter label. Defaults to the built-in translation. */
23
+ hideFilterLabel?: string
12
24
  }>(),
13
25
  {
14
- placeholder: 'Select a template',
26
+ placeholder: 'Select a message group',
15
27
  searchPlaceholder: 'Search...',
16
28
  },
17
29
  )
@@ -22,16 +34,48 @@ const emit = defineEmits<{
22
34
  select: [item: { id: string; name: string }]
23
35
  }>()
24
36
 
37
+ const { t } = useI18n()
38
+
25
39
  const { data: messageGroups, isLoading } = useMessageGroupsQuery(() => ({
26
40
  renderType: props.renderType,
27
41
  }))
28
42
 
43
+ const hideInactive = ref(false)
44
+
45
+ function isGroupActive(messages: Array<{ status: MessageStatus }>): boolean {
46
+ return messages.length > 0 && messages.every((m) => m.status === 'approved')
47
+ }
48
+
29
49
  const items = computed(() => {
30
50
  if (!messageGroups.value) return []
31
- return messageGroups.value.map((group) => ({
32
- label: group.name,
33
- value: group.id,
34
- }))
51
+ return messageGroups.value.reduce<
52
+ Array<{
53
+ label: string
54
+ value: string
55
+ disabled: boolean
56
+ tooltip?: string
57
+ chip: { color: 'success' | 'neutral' }
58
+ }>
59
+ >((acc, group) => {
60
+ const externalReason = props.disabledGroupReasons?.[group.id]
61
+ const externallyUsed = externalReason != null
62
+ const active = isGroupActive(group.messages)
63
+ const disabled = !active || externallyUsed
64
+ if (hideInactive.value && disabled) return acc
65
+ acc.push({
66
+ label: group.name,
67
+ value: group.id,
68
+ disabled,
69
+ // External reason wins over the inactive tooltip when both apply.
70
+ tooltip: externallyUsed
71
+ ? externalReason
72
+ : !active
73
+ ? t('common.messageGroupSelect.inactive_tooltip')
74
+ : undefined,
75
+ chip: { color: disabled ? 'neutral' : 'success' },
76
+ })
77
+ return acc
78
+ }, [])
35
79
  })
36
80
 
37
81
  function handleUpdate(value: string) {
@@ -50,7 +94,23 @@ function handleUpdate(value: string) {
50
94
  searchable
51
95
  :placeholder="placeholder"
52
96
  :search-input="{ placeholder: searchPlaceholder }"
53
- :data-testid="templateSelectTestIds.root"
97
+ :data-testid="messageGroupSelectTestIds.root"
54
98
  @update:model-value="handleUpdate"
55
- />
99
+ >
100
+ <template #item-label="{ item }">
101
+ <UTooltip v-if="item.disabled" :text="item.tooltip">
102
+ <span class="text-muted">{{ item.label }}</span>
103
+ </UTooltip>
104
+ <span v-else>{{ item.label }}</span>
105
+ </template>
106
+
107
+ <template #content-top>
108
+ <div class="flex items-center gap-2 px-2 py-1.5 border-b border-default">
109
+ <USwitch v-model="hideInactive" size="xs" />
110
+ <span class="text-xs text-muted">
111
+ {{ hideFilterLabel ?? t('common.messageGroupSelect.hide_inactive') }}
112
+ </span>
113
+ </div>
114
+ </template>
115
+ </USelectMenu>
56
116
  </template>
@@ -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')"
@@ -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 = {
@@ -614,6 +615,8 @@ export const footerSettingsTestIds = {
614
615
  container: 'footer-settings-container',
615
616
  fontSizeField: 'footer-settings-font-size-field',
616
617
  fontSizeInput: 'footer-settings-font-size-input',
618
+ linkColorInput: 'footer-settings-link-color-input',
619
+ matchTextColorButton: 'footer-settings-match-text-color-button',
617
620
  } as const
618
621
 
619
622
  export const gridBlockTestIds = {
@@ -652,6 +655,8 @@ export const headingSettingsTestIds = {
652
655
  tagSelect: 'heading-settings-tag-select',
653
656
  fontSizeField: 'heading-settings-font-size-field',
654
657
  fontSizeInput: 'heading-settings-font-size-input',
658
+ linkColorInput: 'heading-settings-link-color-input',
659
+ matchTextColorButton: 'heading-settings-match-text-color-button',
655
660
  } as const
656
661
 
657
662
  export const htmlBlockTestIds = {
@@ -690,6 +695,8 @@ export const paragraphSettingsTestIds = {
690
695
  tagSelect: 'paragraph-settings-tag-select',
691
696
  fontSizeField: 'paragraph-settings-font-size-field',
692
697
  fontSizeInput: 'paragraph-settings-font-size-input',
698
+ linkColorInput: 'paragraph-settings-link-color-input',
699
+ matchTextColorButton: 'paragraph-settings-match-text-color-button',
693
700
  } as const
694
701
 
695
702
  export const socialsBlockTestIds = {
@@ -731,6 +738,8 @@ export const unsubscribeLinkSettingsTestIds = {
731
738
  fontSizeInput: 'unsubscribe-link-settings-font-size-input',
732
739
  textColorInput: 'unsubscribe-link-settings-text-color-input',
733
740
  backgroundColorInput: 'unsubscribe-link-settings-background-color-input',
741
+ linkColorInput: 'unsubscribe-link-settings-link-color-input',
742
+ matchTextColorButton: 'unsubscribe-link-settings-match-text-color-button',
734
743
  } as const
735
744
 
736
745
  export const previewByIdTestIds = {
@@ -738,6 +747,9 @@ export const previewByIdTestIds = {
738
747
  loading: 'preview-by-id-loading',
739
748
  } as const
740
749
 
741
- export const templateSelectTestIds = {
742
- root: 'template-select-root',
750
+ export const messageGroupSelectTestIds = {
751
+ root: 'message-group-select-root',
743
752
  } as const
753
+
754
+ /** @deprecated Use `messageGroupSelectTestIds` instead */
755
+ export const templateSelectTestIds = messageGroupSelectTestIds
@@ -54,6 +54,11 @@ export default {
54
54
  search: 'Suchen...',
55
55
  success: 'Erfolg',
56
56
  translation_failed: 'Übersetzung fehlgeschlagen',
57
+ messageGroupSelect: {
58
+ inactive_tooltip:
59
+ 'Diese Nachrichtengruppe enthält nicht genehmigte oder fehlende Nachrichten',
60
+ hide_inactive: 'Inaktive ausblenden',
61
+ },
57
62
  view_less: 'Weniger anzeigen',
58
63
  view_more: 'Mehr anzeigen',
59
64
  },
@@ -96,6 +101,12 @@ export default {
96
101
  success: '„{name}“ wurde veröffentlicht.',
97
102
  title: 'Nachricht veröffentlichen',
98
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
+ },
99
110
  unpublish_message_group: {
100
111
  message:
101
112
  'Veröffentlichung von „{name}“ aufheben? Es ist dann nicht mehr für Kundinnen und Kunden sichtbar.',
@@ -255,6 +266,8 @@ export default {
255
266
  color: 'Farbe',
256
267
  content: 'Inhalt',
257
268
  font: 'Schriftart',
269
+ link: 'Link',
270
+ match_text_color: 'An Textfarbe anpassen',
258
271
  size: 'Größe',
259
272
  text: 'Text',
260
273
  textStyle: 'Textstil',
@@ -608,6 +621,7 @@ export default {
608
621
  change: 'Änderung',
609
622
  changes: 'Änderungen',
610
623
  changes_count: '{count} Änderungen',
624
+ request_review: 'Überprüfung anfordern',
611
625
  translated_percentage: '{percentage}% übersetzt',
612
626
  unsaved_changes: 'Diese Übersetzung hat ungespeicherte Änderungen',
613
627
  },
@@ -620,6 +634,8 @@ export default {
620
634
  meta_submission: {
621
635
  error: 'Meta-Übermittlung mit Fehlern abgeschlossen',
622
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',
623
639
  success: 'Nachrichten an Meta übermittelt',
624
640
  },
625
641
  modals: {
@@ -679,6 +695,7 @@ export default {
679
695
  default_font: 'Standardschriftart',
680
696
  email_bg_color: 'Hintergrund',
681
697
  email_content_bg_color: 'Inhalt-Hintergrund',
698
+ email_link_color: 'Link-Farbe',
682
699
  email_text_color: 'Text',
683
700
  extracting_colors: 'Farben werden aus dem Logo extrahiert...',
684
701
  logo: 'Logo',
@@ -53,6 +53,10 @@ export default {
53
53
  search: 'Search...',
54
54
  success: 'Success',
55
55
  translation_failed: 'Translation failed',
56
+ messageGroupSelect: {
57
+ inactive_tooltip: 'This message group has unapproved or missing messages',
58
+ hide_inactive: 'Hide inactive',
59
+ },
56
60
  view_less: 'View less',
57
61
  view_more: 'View more',
58
62
  },
@@ -93,6 +97,12 @@ export default {
93
97
  success: '"{name}" has been published.',
94
98
  title: 'Publish message',
95
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
+ },
96
106
  unpublish_message_group: {
97
107
  message: 'Unpublish "{name}"? It will no longer be visible to clients.',
98
108
  success: '"{name}" has been unpublished.',
@@ -250,6 +260,8 @@ export default {
250
260
  color: 'Color',
251
261
  content: 'Content',
252
262
  font: 'Font',
263
+ link: 'Link',
264
+ match_text_color: 'Match text color',
253
265
  size: 'Size',
254
266
  text: 'Text',
255
267
  textStyle: 'Text style',
@@ -599,6 +611,7 @@ export default {
599
611
  change: 'change',
600
612
  changes: 'changes',
601
613
  changes_count: '{count} changes',
614
+ request_review: 'Request review',
602
615
  translated_percentage: '{percentage}% translated',
603
616
  unsaved_changes: 'This translation has unsaved changes',
604
617
  },
@@ -611,6 +624,8 @@ export default {
611
624
  meta_submission: {
612
625
  error: 'Meta submission completed with errors',
613
626
  failed_count: 'Failed messages: {count}',
627
+ message_error: 'Failed to submit translation for review',
628
+ message_success: 'Translation submitted for review',
614
629
  success: 'Messages submitted to Meta',
615
630
  },
616
631
  modals: {
@@ -669,6 +684,7 @@ export default {
669
684
  default_font: 'Default font',
670
685
  email_bg_color: 'Background',
671
686
  email_content_bg_color: 'Content background',
687
+ email_link_color: 'Link color',
672
688
  email_text_color: 'Text',
673
689
  extracting_colors: 'Extracting colors from logo...',
674
690
  logo: 'Logo',
@@ -54,6 +54,10 @@ export default {
54
54
  search: 'Buscar...',
55
55
  success: 'Éxito',
56
56
  translation_failed: 'La traducción ha fallado',
57
+ messageGroupSelect: {
58
+ inactive_tooltip: 'Este grupo de mensajes tiene mensajes no aprobados o faltantes',
59
+ hide_inactive: 'Ocultar inactivos',
60
+ },
57
61
  view_less: 'Ver menos',
58
62
  view_more: 'Ver más',
59
63
  },
@@ -94,6 +98,12 @@ export default {
94
98
  success: '"{name}" se ha publicado.',
95
99
  title: 'Publicar mensaje',
96
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
+ },
97
107
  unpublish_message_group: {
98
108
  message: '¿Despublicar "{name}"? Ya no será visible para los clientes.',
99
109
  success: '"{name}" se ha despublicado.',
@@ -251,6 +261,8 @@ export default {
251
261
  color: 'Color',
252
262
  content: 'Contenido',
253
263
  font: 'Fuente',
264
+ link: 'Enlace',
265
+ match_text_color: 'Igualar al color del texto',
254
266
  size: 'Tamaño',
255
267
  text: 'Texto',
256
268
  textStyle: 'Estilo del texto',
@@ -607,6 +619,7 @@ export default {
607
619
  change: 'cambio',
608
620
  changes: 'cambios',
609
621
  changes_count: '{count} cambios',
622
+ request_review: 'Solicitar revisión',
610
623
  translated_percentage: '{percentage}% traducido',
611
624
  unsaved_changes: 'Esta traducción tiene cambios sin guardar',
612
625
  },
@@ -619,6 +632,8 @@ export default {
619
632
  meta_submission: {
620
633
  error: 'Envío a Meta completado con errores',
621
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',
622
637
  success: 'Mensajes enviados a Meta',
623
638
  },
624
639
  modals: {
@@ -678,6 +693,7 @@ export default {
678
693
  default_font: 'Fuente predeterminada',
679
694
  email_bg_color: 'Fondo',
680
695
  email_content_bg_color: 'Fondo contenido',
696
+ email_link_color: 'Color de enlace',
681
697
  email_text_color: 'Texto',
682
698
  extracting_colors: 'Extrayendo colores del logo...',
683
699
  logo: 'Logo',
@@ -54,6 +54,10 @@ export default {
54
54
  search: 'Rechercher...',
55
55
  success: 'Succès',
56
56
  translation_failed: 'Échec de la traduction',
57
+ messageGroupSelect: {
58
+ inactive_tooltip: 'Ce groupe de messages contient des messages non approuvés ou manquants',
59
+ hide_inactive: 'Masquer inactifs',
60
+ },
57
61
  view_less: 'Afficher moins',
58
62
  view_more: 'Afficher plus',
59
63
  },
@@ -94,6 +98,12 @@ export default {
94
98
  success: '« {name} » a été publié.',
95
99
  title: 'Publier le message',
96
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
+ },
97
107
  unpublish_message_group: {
98
108
  message: 'Dépublier « {name} » ? Il ne sera plus visible aux clients.',
99
109
  success: '« {name} » a été dépublié.',
@@ -252,6 +262,8 @@ export default {
252
262
  color: 'Couleur',
253
263
  content: 'Contenu',
254
264
  font: 'Police',
265
+ link: 'Lien',
266
+ match_text_color: 'Assortir à la couleur du texte',
255
267
  size: 'Taille',
256
268
  text: 'Texte',
257
269
  textStyle: 'Style de texte',
@@ -611,6 +623,7 @@ export default {
611
623
  change: 'modification',
612
624
  changes: 'modifications',
613
625
  changes_count: '{count} modifications',
626
+ request_review: 'Demander une révision',
614
627
  translated_percentage: '{percentage}% traduit',
615
628
  unsaved_changes: 'Cette traduction contient des modifications non enregistrées',
616
629
  },
@@ -623,6 +636,8 @@ export default {
623
636
  meta_submission: {
624
637
  error: 'Soumission à Meta terminée avec des erreurs',
625
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',
626
641
  success: 'Messages soumis à Meta',
627
642
  },
628
643
  modals: {
@@ -682,6 +697,7 @@ export default {
682
697
  default_font: 'Police par défaut',
683
698
  email_bg_color: 'Fond',
684
699
  email_content_bg_color: 'Fond contenu',
700
+ email_link_color: 'Couleur du lien',
685
701
  email_text_color: 'Texte',
686
702
  extracting_colors: 'Extraction des couleurs du logo...',
687
703
  logo: 'Logo',
@@ -53,6 +53,10 @@ export default {
53
53
  search: 'Cerca...',
54
54
  success: 'Successo',
55
55
  translation_failed: 'Traduzione non riuscita',
56
+ messageGroupSelect: {
57
+ inactive_tooltip: 'Questo gruppo di messaggi contiene messaggi non approvati o mancanti',
58
+ hide_inactive: 'Nascondi inattivi',
59
+ },
56
60
  view_less: 'Mostra meno',
57
61
  view_more: 'Mostra di più',
58
62
  },
@@ -93,6 +97,12 @@ export default {
93
97
  success: '"{name}" è stato pubblicato.',
94
98
  title: 'Pubblica messaggio',
95
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
+ },
96
106
  unpublish_message_group: {
97
107
  message: 'Annullare la pubblicazione di "{name}"? Non sarà più visibile ai clienti.',
98
108
  success: 'La pubblicazione di "{name}" è stata annullata.',
@@ -251,6 +261,8 @@ export default {
251
261
  color: 'Colore',
252
262
  content: 'Contenuto',
253
263
  font: 'Font',
264
+ link: 'Link',
265
+ match_text_color: 'Abbina al colore del testo',
254
266
  size: 'Dimensione',
255
267
  text: 'Testo',
256
268
  textStyle: 'Stile testo',
@@ -604,6 +616,7 @@ export default {
604
616
  change: 'modifica',
605
617
  changes: 'modifiche',
606
618
  changes_count: '{count} modifiche',
619
+ request_review: 'Richiedi revisione',
607
620
  translated_percentage: '{percentage}% tradotto',
608
621
  unsaved_changes: 'Questa traduzione ha modifiche non salvate',
609
622
  },
@@ -616,6 +629,8 @@ export default {
616
629
  meta_submission: {
617
630
  error: 'Invio a Meta completato con errori',
618
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',
619
634
  success: 'Messaggi inviati a Meta',
620
635
  },
621
636
  modals: {
@@ -675,6 +690,7 @@ export default {
675
690
  default_font: 'Font predefinito',
676
691
  email_bg_color: 'Sfondo',
677
692
  email_content_bg_color: 'Sfondo contenuto',
693
+ email_link_color: 'Colore link',
678
694
  email_text_color: 'Testo',
679
695
  extracting_colors: 'Estrazione dei colori dal logo...',
680
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.10",
3
+ "version": "4.2.11-ux.1",
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.10"
39
+ "@dev.smartpricing/message-composer-utils": "4.2.11-ux.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@nuxt/eslint": "^1.15.2",