@designcrowd/library.brand-page 6.0.13 → 6.0.14-5804-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.
Files changed (96) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-CBjFFeBP.js → CreatedOnBrandCrowd-de-de-BW15_bVX.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-NuNXF81K.js → CreatedOnBrandCrowd-en-us-MXDRWPa1.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-BJG8nfug.js → CreatedOnBrandCrowd-es-es-BkeN_bU4.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-uX-nL53e.js → CreatedOnBrandCrowd-fr-ca-DWPl8vN2.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-uX-nL53e.js → CreatedOnBrandCrowd-fr-fr-DWPl8vN2.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-DTrgFGQV.js → CreatedOnBrandCrowd-pt-br-B2_xFYID.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-DTrgFGQV.js → CreatedOnBrandCrowd-pt-pt-B2_xFYID.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-DSfpxioT.js → CreatedOnDcom-de-de-D-Y_NKQ3.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-C7G4gKem.js → CreatedOnDcom-en-us-TkzWmLPE.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-DVckCaji.js → CreatedOnDcom-es-es-BRcN78-W.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-C5GDrm_C.js → CreatedOnDcom-fr-ca-DcpgnOV-.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-v5eMm-j2.js → CreatedOnDcom-fr-fr-CdppomFF.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-Dq3-fPUc.js → CreatedOnDcom-pt-br-Dm05huFx.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-DFsncYax.js → CreatedOnDcom-pt-pt-C2O9rV7u.js} +1 -1
  15. package/lib/src/brand-page/components/contact-form/ContactForm.mixin.d.ts +1 -0
  16. package/lib/src/brand-page/components/contact-form/ContactForm.mixin.js +19 -1
  17. package/lib/src/brand-page/components/mailing-list/MailingList.mixin.d.ts +1 -0
  18. package/lib/src/brand-page/components/mailing-list/MailingList.mixin.js +8 -1
  19. package/lib/src/brand-page/utils/color.util.d.ts +16 -0
  20. package/lib/src/brand-page/utils/color.util.js +56 -0
  21. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +1 -0
  22. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.js +5 -0
  23. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +1 -1
  24. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.js +4 -6
  25. package/lib/src/brand-page-maker/composables/useQuickLinkBarVisibility.d.ts +17 -0
  26. package/lib/src/brand-page-maker/composables/useQuickLinkBarVisibility.js +23 -0
  27. package/lib/src/bundled-translations-2HkRBbBD.js +177 -0
  28. package/lib/src/bundled-translations.de-DE-DmmZFmGP.js +177 -0
  29. package/lib/src/bundled-translations.es-ES-D7JmTUKc.js +177 -0
  30. package/lib/src/bundled-translations.fr-CA-LANZnNuX.js +177 -0
  31. package/lib/src/bundled-translations.fr-FR-BQybH6dF.js +177 -0
  32. package/lib/src/bundled-translations.pt-BR-DRAIsAdg.js +177 -0
  33. package/lib/src/bundled-translations.pt-PT-CEqBjj6N.js +177 -0
  34. package/lib/src/bundles/bundled-translations.de-DE.json +5 -2
  35. package/lib/src/bundles/bundled-translations.es-ES.json +5 -2
  36. package/lib/src/bundles/bundled-translations.fr-CA.json +5 -2
  37. package/lib/src/bundles/bundled-translations.fr-FR.json +5 -2
  38. package/lib/src/bundles/bundled-translations.json +5 -2
  39. package/lib/src/bundles/bundled-translations.pt-BR.json +5 -2
  40. package/lib/src/bundles/bundled-translations.pt-PT.json +5 -2
  41. package/lib/src/composables/useTranslate.d.ts +2 -1
  42. package/lib/src/composables/useTranslate.js +2 -1
  43. package/lib/src/css/library.brand-page-brandCrowd.min.css +82 -65
  44. package/lib/src/css/library.brand-page-brandPage.min.css +82 -65
  45. package/lib/src/css/library.brand-page-designCom.min.css +82 -65
  46. package/lib/src/css/library.brand-page-preview.min.css +82 -65
  47. package/lib/src/index.mjs +1 -1
  48. package/lib/src/{lib-BG-pyXi9.js → lib-DwLhDsXL.js} +14511 -14413
  49. package/package.json +1 -1
  50. package/src/brand-page/components/button/Button.vue +20 -0
  51. package/src/brand-page/components/button/Button.vue.test.ts +175 -0
  52. package/src/brand-page/components/contact-form/ContactForm.mixin.test.ts +59 -0
  53. package/src/brand-page/components/contact-form/ContactForm.mixin.ts +24 -0
  54. package/src/brand-page/components/contact-form/ContactForm.vue +9 -0
  55. package/src/brand-page/components/mailing-list/MailingList.mixin.ts +11 -0
  56. package/src/brand-page/components/mailing-list/MailingList.vue +3 -0
  57. package/src/brand-page/components/page/Page.vue +9 -0
  58. package/src/brand-page/components/recaptcha-disclosure/RecaptchaDisclosure.vue +60 -0
  59. package/src/brand-page/components/recaptcha-disclosure/RecaptchaDisclosure.vue.test.ts +36 -0
  60. package/src/brand-page/components/recaptcha-disclosure/i18n/recaptcha.de-DE.json +5 -0
  61. package/src/brand-page/components/recaptcha-disclosure/i18n/recaptcha.es-ES.json +5 -0
  62. package/src/brand-page/components/recaptcha-disclosure/i18n/recaptcha.fr-CA.json +5 -0
  63. package/src/brand-page/components/recaptcha-disclosure/i18n/recaptcha.fr-FR.json +5 -0
  64. package/src/brand-page/components/recaptcha-disclosure/i18n/recaptcha.json +5 -0
  65. package/src/brand-page/components/recaptcha-disclosure/i18n/recaptcha.pt-BR.json +5 -0
  66. package/src/brand-page/components/recaptcha-disclosure/i18n/recaptcha.pt-PT.json +5 -0
  67. package/src/brand-page/utils/color.util.test.ts +78 -1
  68. package/src/brand-page/utils/color.util.ts +82 -0
  69. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.ts +5 -0
  70. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.vue +2 -2
  71. package/src/brand-page-maker/components/device/Device.vue +0 -1
  72. package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.ts +4 -7
  73. package/src/brand-page-maker/composables/useQuickLinkBarVisibility.test.ts +81 -0
  74. package/src/brand-page-maker/composables/useQuickLinkBarVisibility.ts +25 -0
  75. package/src/bundles/bundled-translations.de-DE.json +5 -2
  76. package/src/bundles/bundled-translations.es-ES.json +5 -2
  77. package/src/bundles/bundled-translations.fr-CA.json +5 -2
  78. package/src/bundles/bundled-translations.fr-FR.json +5 -2
  79. package/src/bundles/bundled-translations.json +5 -2
  80. package/src/bundles/bundled-translations.pt-BR.json +5 -2
  81. package/src/bundles/bundled-translations.pt-PT.json +5 -2
  82. package/src/composables/useTranslate.ts +3 -0
  83. package/src/shared/content-settings/components/content-settings/i18n/content-settings.de-DE.json +2 -2
  84. package/src/shared/content-settings/components/content-settings/i18n/content-settings.es-ES.json +2 -2
  85. package/src/shared/content-settings/components/content-settings/i18n/content-settings.fr-CA.json +2 -2
  86. package/src/shared/content-settings/components/content-settings/i18n/content-settings.fr-FR.json +2 -2
  87. package/src/shared/content-settings/components/content-settings/i18n/content-settings.json +2 -2
  88. package/src/shared/content-settings/components/content-settings/i18n/content-settings.pt-BR.json +2 -2
  89. package/src/shared/content-settings/components/content-settings/i18n/content-settings.pt-PT.json +2 -2
  90. package/lib/src/bundled-translations-BzzINHD2.js +0 -175
  91. package/lib/src/bundled-translations.de-DE-opJOfLO-.js +0 -175
  92. package/lib/src/bundled-translations.es-ES-MaidBiW6.js +0 -175
  93. package/lib/src/bundled-translations.fr-CA-CuG0285l.js +0 -175
  94. package/lib/src/bundled-translations.fr-FR-DWh-DHys.js +0 -175
  95. package/lib/src/bundled-translations.pt-BR-DG6n1YPu.js +0 -175
  96. package/lib/src/bundled-translations.pt-PT-J5-Lhk0H.js +0 -175
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designcrowd/library.brand-page",
3
3
  "description": "A collection of Maker/Designer applications",
4
- "version": "6.0.13",
4
+ "version": "6.0.14-5804-2",
5
5
  "author": "Design.com Eng",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
@@ -12,6 +12,7 @@
12
12
  class="tw-no-underline"
13
13
  :to="{ name: routes.name, params: routes.routeParams }"
14
14
  :class="{ 'tw-w-full': isBrushStrokeIcon }"
15
+ @click="onPageLinkClick"
15
16
  >
16
17
  <div
17
18
  :title="viewModel.title"
@@ -101,6 +102,7 @@
101
102
 
102
103
  <script setup lang="ts">
103
104
  import { computed, ref } from 'vue';
105
+ import { useRouter } from 'vue-router';
104
106
  import { HashRouteModal } from '@designcrowd/fe-shared-lib';
105
107
  import { isEmail, isTel, isURL } from '../../../utils/validator.util';
106
108
  import type { BrandPageContext } from '@brand-page/models';
@@ -231,6 +233,24 @@ const buttonStateClass = computed(() => ({
231
233
  'tw-w-full': isBrushStrokeIcon.value,
232
234
  }));
233
235
 
236
+ const router = useRouter();
237
+
238
+ const onPageLinkClick = (): void => {
239
+ // A page-link pointing to its own page (e.g. a "back to top" button) resolves
240
+ // to the current route. Vue Router dedupes that as a redundant navigation and
241
+ // never runs the global guard that scrolls to top, so trigger it explicitly.
242
+ if (!router) return;
243
+
244
+ const target = router.resolve({
245
+ name: routes.value.name,
246
+ params: routes.value.routeParams,
247
+ });
248
+
249
+ if (target.fullPath === router.currentRoute.value.fullPath) {
250
+ window.scrollTo(0, 0);
251
+ }
252
+ };
253
+
234
254
  const onClick = (): void => {
235
255
  if (isModalUrl.value && isModalImageUrlValid.value) {
236
256
  isModalVisible.value = true;
@@ -0,0 +1,175 @@
1
+ import { vi } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import { createRouter, createMemoryHistory, RouterLink } from 'vue-router';
4
+ import { createPinia, setActivePinia } from 'pinia';
5
+ import Button from './Button.vue';
6
+ import {
7
+ BRAND_PAGE_TYPES,
8
+ MEDIA_SIZES,
9
+ type BrandPageContext,
10
+ } from '@brand-page/models';
11
+ import type {
12
+ ButtonViewModel,
13
+ NavItemViewModel,
14
+ } from '@brand-page/view-models';
15
+
16
+ global.ResizeObserver = vi.fn().mockImplementation(() => ({
17
+ observe: vi.fn(),
18
+ unobserve: vi.fn(),
19
+ disconnect: vi.fn(),
20
+ }));
21
+
22
+ const LINKED_PAGE_ID = 'page-2';
23
+ const LINKED_PAGE_PATH = '/my-brand/second-page';
24
+ const OTHER_PAGE_PATH = '/my-brand';
25
+
26
+ const createTestNavItem = (): NavItemViewModel => ({
27
+ pageId: LINKED_PAGE_ID,
28
+ slug: 'my-brand',
29
+ displayName: 'Second Page',
30
+ routeName: 'page-route',
31
+ routeParams: { slug: 'my-brand', pageSlug: 'second-page' },
32
+ isRoot: false,
33
+ isHidden: false,
34
+ relativePath: 'second-page',
35
+ nestedNavItems: [],
36
+ });
37
+
38
+ const createTestBrandPageContext = (): BrandPageContext => ({
39
+ brandPageSlug: 'my-brand',
40
+ brandPageToken: '',
41
+ isMobile: false,
42
+ isPreview: true,
43
+ isProduction: false,
44
+ isServerRender: false,
45
+ options: {
46
+ mediaSizes: MEDIA_SIZES.sizeThumb,
47
+ },
48
+ isTemplate: false,
49
+ isDesignCom: false,
50
+ pageTitle: 'My Brand',
51
+ runtimeEnvironment: '',
52
+ stripePublishableKey: undefined,
53
+ customDomain: undefined,
54
+ brandPageType: BRAND_PAGE_TYPES.brandSite,
55
+ isBrandPage: false,
56
+ isBrandPageOrIsBrandContact: false,
57
+ isBrandSite: true,
58
+ isBrandContact: false,
59
+ pages: [],
60
+ isCapturingPreview: false,
61
+ recaptchaSiteKey: undefined,
62
+ nav: {
63
+ navItems: [createTestNavItem()],
64
+ },
65
+ });
66
+
67
+ const textStyle = {
68
+ fontSize: '1rem',
69
+ lineHeight: '1.5rem',
70
+ fontFamily: 'Lato',
71
+ textAlign: 'center',
72
+ overflowWrap: 'anywhere',
73
+ wordBreak: 'normal',
74
+ };
75
+
76
+ const createTestButtonViewModel = (
77
+ overrides: Partial<ButtonViewModel> = {},
78
+ ): ButtonViewModel =>
79
+ ({
80
+ id: 'button-1',
81
+ type: 'button',
82
+ role: 'banner-section',
83
+ title: 'Go to top',
84
+ url: '',
85
+ page: LINKED_PAGE_ID,
86
+ pageSlug: '',
87
+ buttonLinkType: 'page',
88
+ clickActionField: 'url',
89
+ iconUrl: '',
90
+ modalImageUrl: '',
91
+ titleStyle: textStyle,
92
+ styles: { selectedButtonStyleId: 'BUTTON_STYLE_1', style: {} },
93
+ onClicked: vi.fn(),
94
+ ...overrides,
95
+ }) as ButtonViewModel;
96
+
97
+ const createTestRouter = () =>
98
+ createRouter({
99
+ history: createMemoryHistory(),
100
+ routes: [
101
+ {
102
+ path: '/:slug/:pageSlug',
103
+ name: 'page-route',
104
+ component: { template: '<div />' },
105
+ },
106
+ {
107
+ path: '/:slug',
108
+ name: 'home-route',
109
+ component: { template: '<div />' },
110
+ },
111
+ ],
112
+ });
113
+
114
+ const mountButton = async (currentPath: string) => {
115
+ const router = createTestRouter();
116
+
117
+ router.push(currentPath);
118
+ await router.isReady();
119
+
120
+ const wrapper = mount(Button, {
121
+ propsData: {
122
+ context: createTestBrandPageContext(),
123
+ viewModel: createTestButtonViewModel(),
124
+ },
125
+ global: {
126
+ plugins: [router],
127
+ },
128
+ });
129
+
130
+ return { wrapper, router };
131
+ };
132
+
133
+ describe('Button - page link "back to top" behaviour (BP-5834)', () => {
134
+ let scrollToSpy: ReturnType<typeof vi.spyOn>;
135
+
136
+ beforeEach(() => {
137
+ setActivePinia(createPinia());
138
+ scrollToSpy = vi
139
+ .spyOn(window, 'scrollTo')
140
+ .mockImplementation(() => undefined);
141
+ });
142
+
143
+ afterEach(() => {
144
+ scrollToSpy.mockRestore();
145
+ });
146
+
147
+ test('scrolls to top when the page-link target is the current route (duplicated navigation is skipped by the router)', async () => {
148
+ // A banner "back to top" button points to the page it lives on. Vue Router
149
+ // dedupes the navigation to the current location and never runs the guard
150
+ // that scrolls to top, so the button must scroll explicitly.
151
+ const { wrapper } = await mountButton(LINKED_PAGE_PATH);
152
+
153
+ const link = wrapper.findComponent(RouterLink);
154
+
155
+ expect(link.exists()).toBe(true);
156
+
157
+ await link.get('a').trigger('click');
158
+
159
+ expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
160
+ });
161
+
162
+ test('does not scroll to top when the page-link target is a different page', async () => {
163
+ // A page-link to another page is a real navigation; the router guard owns
164
+ // scroll-to-top there, so the button must not scroll on its own.
165
+ const { wrapper } = await mountButton(OTHER_PAGE_PATH);
166
+
167
+ const link = wrapper.findComponent(RouterLink);
168
+
169
+ expect(link.exists()).toBe(true);
170
+
171
+ await link.get('a').trigger('click');
172
+
173
+ expect(scrollToSpy).not.toHaveBeenCalled();
174
+ });
175
+ });
@@ -0,0 +1,59 @@
1
+ import ContactFormMixin from './ContactForm.mixin';
2
+
3
+ // Calls the disclosureColor computed with a mocked `this`. It reads the sibling
4
+ // computeds (hasCard/isDividedContent/hasBgColor) as plain properties, so they
5
+ // can be supplied directly on the mock.
6
+ const disclosureColor = (ctx: unknown): string | undefined =>
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ (ContactFormMixin as any).computed.disclosureColor.call(ctx);
9
+
10
+ describe('ContactForm disclosureColor', () => {
11
+ test('brand.page / brand.contact anchor to the global button fill colour', () => {
12
+ const color = disclosureColor({
13
+ context: { isBrandSite: false },
14
+ hasCard: false,
15
+ isDividedContent: false,
16
+ hasBgColor: false,
17
+ viewModel: {
18
+ buttonStyles: { style: { backgroundColor: '#101010' } }, // dark fill
19
+ titleStyle: { color: '#ff0000' },
20
+ },
21
+ });
22
+
23
+ // Anchored to the dark button fill → a lighter colour, not the title red.
24
+ expect(color).toBeDefined();
25
+ expect(color).not.toBe('#ff0000');
26
+ });
27
+
28
+ test('brandSite card layout anchors to the card background', () => {
29
+ const color = disclosureColor({
30
+ context: { isBrandSite: true },
31
+ hasCard: true,
32
+ isDividedContent: false,
33
+ hasBgColor: true,
34
+ viewModel: {
35
+ cardStyle: { background: '#f5f0e6' }, // light beige card (background shorthand)
36
+ buttonStyles: { style: { backgroundColor: '#101010' } },
37
+ titleStyle: { color: '#ff0000' },
38
+ },
39
+ });
40
+
41
+ expect(color).toBeDefined();
42
+ expect(color).not.toBe('#ff0000');
43
+ });
44
+
45
+ test('falls back to the title colour when no solid background is available', () => {
46
+ const color = disclosureColor({
47
+ context: { isBrandSite: true },
48
+ hasCard: false,
49
+ isDividedContent: false,
50
+ hasBgColor: false,
51
+ viewModel: {
52
+ buttonStyles: { style: {} },
53
+ titleStyle: { color: '#123456' },
54
+ },
55
+ });
56
+
57
+ expect(color).toBe('#123456');
58
+ });
59
+ });
@@ -28,6 +28,8 @@ import {
28
28
  import {
29
29
  getRenderText,
30
30
  contentPaddingStyle as toContentPaddingStyle,
31
+ mutedTextForBackground,
32
+ backgroundColorFromStyle,
31
33
  } from '@brand-page/utils';
32
34
  import { hasRTEContent } from '@brand-page-maker/utils';
33
35
  import { useReCaptcha } from '../../composables/recaptcha.composable';
@@ -206,6 +208,28 @@ export default defineComponent({
206
208
 
207
209
  return {};
208
210
  },
211
+
212
+ disclosureColor(): string | undefined {
213
+ // Anchor the reCAPTCHA footnote to the surface it visually sits on, which
214
+ // differs by website type:
215
+ // - brandSite: the card style (card + divided layouts) or the section
216
+ // background (other layouts).
217
+ // - brand.page / brand.contact: there's no section background, so the
218
+ // coloured surface is the global button fill.
219
+ // Falls back to the title colour when there's no solid colour to anchor to.
220
+ const style = this.context.isBrandSite
221
+ ? this.hasCard || this.isDividedContent
222
+ ? this.viewModel.cardStyle
223
+ : this.hasBgColor
224
+ ? this.viewModel.backgroundStyle
225
+ : undefined
226
+ : this.viewModel.buttonStyles?.style;
227
+
228
+ return (
229
+ mutedTextForBackground(backgroundColorFromStyle(style)) ??
230
+ this.viewModel.titleStyle?.color
231
+ );
232
+ },
209
233
  },
210
234
  watch: {
211
235
  fields(fields: FormField[]) {
@@ -98,6 +98,7 @@
98
98
  <p v-if="hasSubmitError" class="tw-text-center tw-mt-2 form-error">
99
99
  {{ submitError }}
100
100
  </p>
101
+ <RecaptchaDisclosure class="tw-pt-2" :color="disclosureColor" />
101
102
  </form>
102
103
  <div v-else :style="successMessageColorStyle">
103
104
  <TextWrapper
@@ -206,6 +207,7 @@
206
207
  >
207
208
  {{ submitError }}
208
209
  </p>
210
+ <RecaptchaDisclosure class="tw-pt-2" :color="disclosureColor" />
209
211
  </form>
210
212
  <div v-else :style="successMessageColorStyle">
211
213
  <TextWrapper
@@ -311,6 +313,7 @@
311
313
  <p v-if="hasSubmitError" class="tw-text-center tw-mt-2 form-error">
312
314
  {{ submitError }}
313
315
  </p>
316
+ <RecaptchaDisclosure class="tw-pt-2" :color="disclosureColor" />
314
317
  </form>
315
318
  <Loader
316
319
  v-if="isSubmitting"
@@ -446,6 +449,10 @@
446
449
  >
447
450
  {{ submitError }}
448
451
  </p>
452
+ <RecaptchaDisclosure
453
+ class="tw-pt-2"
454
+ :color="disclosureColor"
455
+ />
449
456
  </form>
450
457
  </span>
451
458
  <div v-else :style="successMessageColorStyle">
@@ -469,6 +476,7 @@ import Collapsible from '../collapsible/Collapsible.vue';
469
476
  import MailingListMixin from './ContactForm.mixin.ts';
470
477
  import Media from '../media/Media.vue';
471
478
  import TextWrapper from '../text-wrapper/TextWrapper.vue';
479
+ import RecaptchaDisclosure from '../recaptcha-disclosure/RecaptchaDisclosure.vue';
472
480
 
473
481
  export default {
474
482
  components: {
@@ -478,6 +486,7 @@ export default {
478
486
  Loader,
479
487
  Media,
480
488
  TextWrapper,
489
+ RecaptchaDisclosure,
481
490
  },
482
491
  mixins: [MailingListMixin],
483
492
  setup() {
@@ -5,6 +5,8 @@ import brandPageClient from '../../clients/brand-page.client';
5
5
  import {
6
6
  getEmailValidationError,
7
7
  contentPaddingStyle as toContentPaddingStyle,
8
+ mutedTextForBackground,
9
+ backgroundColorFromStyle,
8
10
  } from '@brand-page/utils';
9
11
  import type { BrandPageContext } from '@brand-page/models';
10
12
  import {
@@ -88,6 +90,15 @@ export default defineComponent({
88
90
  return Boolean(this.submitError);
89
91
  },
90
92
 
93
+ disclosureColor(): string | undefined {
94
+ // The mailing-list box uses the button style as its background, so anchor
95
+ // the reCAPTCHA footnote to that. Falls back to the title colour when the
96
+ // background isn't a solid colour.
97
+ const bg = backgroundColorFromStyle(this.viewModel.styles?.style);
98
+
99
+ return mutedTextForBackground(bg) ?? this.viewModel.titleStyle?.color;
100
+ },
101
+
91
102
  buttonStyle(): BrandPageCssStyle {
92
103
  const buttonStyle = { ...this.viewModel.styles?.style };
93
104
 
@@ -60,6 +60,7 @@
60
60
  <p v-if="hasSubmitError" class="tw-text-center tw-mt-2 form-error">
61
61
  {{ submitError }}
62
62
  </p>
63
+ <RecaptchaDisclosure class="tw-pt-2" :color="disclosureColor" />
63
64
  </form>
64
65
  <div v-else>
65
66
  <p class="tw-text-center tw-font-bold tw-my-0.5">
@@ -73,11 +74,13 @@
73
74
  import { TextInput } from '@designcrowd/fe-shared-lib';
74
75
  import MailingListMixin from './MailingList.mixin.ts';
75
76
  import TextWrapper from '../text-wrapper/TextWrapper.vue';
77
+ import RecaptchaDisclosure from '../recaptcha-disclosure/RecaptchaDisclosure.vue';
76
78
 
77
79
  export default {
78
80
  components: {
79
81
  TextInput,
80
82
  TextWrapper,
83
+ RecaptchaDisclosure,
81
84
  },
82
85
  mixins: [MailingListMixin],
83
86
  setup() {
@@ -338,6 +338,15 @@ body,
338
338
  body {
339
339
  line-height: 1.5;
340
340
  }
341
+ /*
342
+ * Hide Google's floating reCAPTCHA badge. Google's Terms of Service permit this
343
+ * only when the reCAPTCHA branding is disclosed in the form flow instead — see
344
+ * RecaptchaDisclosure.vue, rendered by ContactForm and MailingList. Using
345
+ * visibility (not display:none) keeps the invisible-challenge flow working.
346
+ */
347
+ .grecaptcha-badge {
348
+ visibility: hidden !important;
349
+ }
341
350
  </style>
342
351
 
343
352
  <style scoped>
@@ -0,0 +1,60 @@
1
+ <!-- eslint-disable vue/no-v-html -->
2
+ <template>
3
+ <!--
4
+ Google reCAPTCHA branding disclosure. Required by Google's Terms of Service
5
+ whenever the floating reCAPTCHA badge is hidden (see Page.vue). The copy is
6
+ static, trusted i18n content that includes the Privacy Policy / Terms of
7
+ Service links, so it is rendered with v-html.
8
+
9
+ Rendered only after mount (client-side). The web-renderer attaches the
10
+ initialised i18next instance via a server-only plugin, but on the initial
11
+ SSR pass `recaptchaTr` can resolve against an uninitialised instance and emit
12
+ the raw key. Deferring to the client (where i18next is always ready) mirrors
13
+ how the mailing-list form renders and guarantees the copy resolves.
14
+ -->
15
+ <p
16
+ v-if="isMounted"
17
+ data-component="recaptcha-disclosure"
18
+ class="recaptcha-disclosure"
19
+ :style="color ? { color } : undefined"
20
+ v-html="recaptchaTr('disclosure')"
21
+ />
22
+ </template>
23
+ <script>
24
+ import { ref, onMounted } from 'vue';
25
+ import { recaptchaTr } from '@src/composables/useTranslate';
26
+
27
+ export default {
28
+ props: {
29
+ // Text colour for the disclosure; forms pass their title colour so the
30
+ // footnote follows the title style. Links inherit this colour.
31
+ color: {
32
+ type: String,
33
+ required: false,
34
+ default: undefined,
35
+ },
36
+ },
37
+ setup() {
38
+ const isMounted = ref(false);
39
+
40
+ onMounted(() => {
41
+ isMounted.value = true;
42
+ });
43
+
44
+ return {
45
+ recaptchaTr,
46
+ isMounted,
47
+ };
48
+ },
49
+ };
50
+ </script>
51
+
52
+ <style scoped>
53
+ .recaptcha-disclosure {
54
+ @apply tw-text-xs tw-text-center tw-mt-3;
55
+ }
56
+ .recaptcha-disclosure :deep(a) {
57
+ color: inherit;
58
+ text-decoration: underline;
59
+ }
60
+ </style>
@@ -0,0 +1,36 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { setLocaleAsync } from '@src/composables/useTranslate';
3
+ import RecaptchaDisclosure from './RecaptchaDisclosure.vue';
4
+
5
+ describe('RecaptchaDisclosure', () => {
6
+ test('renders the resolved disclosure copy after mount', async () => {
7
+ await setLocaleAsync('en-US');
8
+
9
+ const wrapper = mount(RecaptchaDisclosure);
10
+
11
+ await wrapper.vm.$nextTick();
12
+
13
+ const el = wrapper.find('[data-component="recaptcha-disclosure"]');
14
+
15
+ expect(el.exists()).toBe(true);
16
+ expect(el.element.tagName).toBe('P');
17
+ // Resolves the i18n key rather than emitting the raw `recaptcha.disclosure`.
18
+ expect(el.text()).toContain('Protected by reCAPTCHA');
19
+ expect(el.text()).not.toContain('recaptcha.disclosure');
20
+ // Links to Google's Privacy Policy / Terms are present.
21
+ expect(el.html()).toContain('https://policies.google.com/privacy');
22
+ expect(el.html()).toContain('https://policies.google.com/terms');
23
+ });
24
+
25
+ test('applies the provided color so it follows the title style', async () => {
26
+ const wrapper = mount(RecaptchaDisclosure, {
27
+ props: { color: 'rgb(10, 20, 30)' },
28
+ });
29
+
30
+ await wrapper.vm.$nextTick();
31
+
32
+ const el = wrapper.find('[data-component="recaptcha-disclosure"]');
33
+
34
+ expect(el.attributes('style')).toContain('color: rgb(10, 20, 30)');
35
+ });
36
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "recaptcha": {
3
+ "disclosure": "Geschützt durch reCAPTCHA (&lt;a href=&quot;https://policies.google.com/privacy\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Datenschutz</a> / &lt;a href=&quot;https://policies.google.com/terms\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Nutzungsbedingungen</a>)"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "recaptcha": {
3
+ "disclosure": "Protegido por reCAPTCHA (&lt;a href=&quot;https://policies.google.com/privacy\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Privacidad</a> / &lt;a href=&quot;https://policies.google.com/terms\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Términos</a>)"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "recaptcha": {
3
+ "disclosure": "Protégé par reCAPTCHA (&lt;a href=&quot;https://policies.google.com/privacy\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Confidentialité</a> / &lt;a href=&quot;https://policies.google.com/terms\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Conditions</a>)"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "recaptcha": {
3
+ "disclosure": "Protégé par reCAPTCHA (&lt;a href=&quot;https://policies.google.com/privacy\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Confidentialité</a> / &lt;a href=&quot;https://policies.google.com/terms\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Conditions</a>)"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "recaptcha": {
3
+ "disclosure": "Protected by reCAPTCHA (<a href=\"https://policies.google.com/privacy\" target=\"_blank\" rel=\"noopener noreferrer\">Privacy</a> / <a href=\"https://policies.google.com/terms\" target=\"_blank\" rel=\"noopener noreferrer\">Terms</a>)"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "recaptcha": {
3
+ "disclosure": "Protegido pelo reCAPTCHA (&lt;a href=&quot;https://policies.google.com/privacy\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Termos de</a> / &lt;a href=&quot;https://policies.google.com/terms\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Privacidade</a>)"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "recaptcha": {
3
+ "disclosure": "Protegido pelo reCAPTCHA (&lt;a href=&quot;https://policies.google.com/privacy\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Termos de </a> / &lt;a href=&quot;https://policies.google.com/terms\" target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Privacidade</a>)"
4
+ }
5
+ }
@@ -1,4 +1,16 @@
1
- import { getNewColorBasedOnTargetColorTheme } from './color.util';
1
+ import Color from 'color';
2
+ import {
3
+ backgroundColorFromStyle,
4
+ getNewColorBasedOnTargetColorTheme,
5
+ mutedTextForBackground,
6
+ } from './color.util';
7
+
8
+ const wcagContrast = (a: string, b: string): number => {
9
+ const la = Color(a).luminosity();
10
+ const lb = Color(b).luminosity();
11
+
12
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
13
+ };
2
14
 
3
15
  describe('color.util - getNewColorBasedOnTargetColorTheme', () => {
4
16
  [
@@ -46,6 +58,71 @@ describe('color.util - getNewColorBasedOnTargetColorTheme', () => {
46
58
  });
47
59
  });
48
60
 
61
+ describe('color.util - mutedTextForBackground', () => {
62
+ test('light background (beige) yields darker, legible text', () => {
63
+ const bg = '#f5f0e6';
64
+ const text = mutedTextForBackground(bg)!;
65
+
66
+ expect(text).toBeDefined();
67
+ // Darker than the background.
68
+ expect(Color(text).luminosity()).toBeLessThan(Color(bg).luminosity());
69
+ // Legible.
70
+ expect(wcagContrast(text, bg)).toBeGreaterThanOrEqual(3.4);
71
+ });
72
+
73
+ test('dark background yields lighter, legible text', () => {
74
+ const bg = '#1a2233';
75
+ const text = mutedTextForBackground(bg)!;
76
+
77
+ expect(text).toBeDefined();
78
+ // Lighter than the background.
79
+ expect(Color(text).luminosity()).toBeGreaterThan(Color(bg).luminosity());
80
+ expect(wcagContrast(text, bg)).toBeGreaterThanOrEqual(3.4);
81
+ });
82
+
83
+ test('mid-grey background still resolves to a legible colour', () => {
84
+ const bg = '#808080';
85
+ const text = mutedTextForBackground(bg)!;
86
+
87
+ expect(text).toBeDefined();
88
+ expect(wcagContrast(text, bg)).toBeGreaterThanOrEqual(3.4);
89
+ });
90
+
91
+ test('honours a higher target contrast', () => {
92
+ const bg = '#f5f0e6';
93
+ const text = mutedTextForBackground(bg, { targetContrast: 7 })!;
94
+
95
+ expect(wcagContrast(text, bg)).toBeGreaterThanOrEqual(6.9);
96
+ });
97
+
98
+ test('returns undefined for non-solid / unknown backgrounds', () => {
99
+ expect(mutedTextForBackground(undefined)).toBeUndefined();
100
+ expect(mutedTextForBackground('')).toBeUndefined();
101
+ expect(mutedTextForBackground('url(bg.png)')).toBeUndefined();
102
+ expect(mutedTextForBackground('transparent')).toBeUndefined();
103
+ expect(mutedTextForBackground('rgba(0,0,0,0)')).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe('color.util - backgroundColorFromStyle', () => {
108
+ test('prefers backgroundColor (card/button styles)', () => {
109
+ expect(backgroundColorFromStyle({ backgroundColor: '#123456' })).toBe(
110
+ '#123456',
111
+ );
112
+ });
113
+
114
+ test('falls back to the `background` shorthand (section styles)', () => {
115
+ expect(backgroundColorFromStyle({ background: 'rgb(1, 2, 3)' })).toBe(
116
+ 'rgb(1, 2, 3)',
117
+ );
118
+ });
119
+
120
+ test('returns undefined for empty / missing styles', () => {
121
+ expect(backgroundColorFromStyle(undefined)).toBeUndefined();
122
+ expect(backgroundColorFromStyle({})).toBeUndefined();
123
+ });
124
+ });
125
+
49
126
  describe('color.util - getNewColorBasedOnTargetColorTheme near-grayscale snap', () => {
50
127
  // Reproducer for LangSmith trace 4e116ea3 (the "orangy drum"). The trio's
51
128
  // secondary was #fffcfa — perceptually white but HSL(24°, 100%, 99%). Without