@designcrowd/library.brand-page 6.2.0 → 6.2.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.
Files changed (121) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-VQCQvlMg.js → CreatedOnBrandCrowd-de-de-CtvPtYF6.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-DSf3BjCG.js → CreatedOnBrandCrowd-en-us-eGF-EjAX.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-DGcdM9Ec.js → CreatedOnBrandCrowd-es-es-CSemsOtT.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-C9g1fc39.js → CreatedOnBrandCrowd-fr-ca-DzylX-AT.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-C9g1fc39.js → CreatedOnBrandCrowd-fr-fr-DzylX-AT.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-DSXr7Bxl.js → CreatedOnBrandCrowd-pt-br-QMUxXQsE.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-DSXr7Bxl.js → CreatedOnBrandCrowd-pt-pt-QMUxXQsE.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-BPdCL6wS.js → CreatedOnDcom-de-de-M7OoJt-1.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-CLkSTQRu.js → CreatedOnDcom-en-us-D7hMM0AI.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CRknMAGv.js → CreatedOnDcom-es-es-CLw-BLp2.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-ClAY_dHI.js → CreatedOnDcom-fr-ca-BRu_kmeu.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-DQtv9rR-.js → CreatedOnDcom-fr-fr-IMnEv_yM.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-BbIxvmxv.js → CreatedOnDcom-pt-br-Bt6v1p27.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-BOfOesBj.js → CreatedOnDcom-pt-pt-BVsFJIJg.js} +1 -1
  15. package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.d.ts +16 -2
  16. package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.js +17 -0
  17. package/lib/src/brand-page/components/page/Page.mixin.d.ts +2 -1
  18. package/lib/src/brand-page/components/page/Page.mixin.js +12 -7
  19. package/lib/src/brand-page/components/page-header/HeaderLayout.stories.d.ts +1 -0
  20. package/lib/src/brand-page/components/page-header/HeaderLayout.stories.js +9 -0
  21. package/lib/src/brand-page/components/page-header/PageHeader.mixin.d.ts +3 -1
  22. package/lib/src/brand-page/components/page-header/PageHeader.mixin.js +10 -4
  23. package/lib/src/brand-page/constants/sections/header-display.constants.d.ts +3 -0
  24. package/lib/src/brand-page/constants/sections/header-display.constants.js +15 -0
  25. package/lib/src/brand-page/models/index.d.ts +12 -0
  26. package/lib/src/brand-page/models/index.js +4 -0
  27. package/lib/src/brand-page/utils/context.util.d.ts +1 -0
  28. package/lib/src/brand-page/utils/context.util.js +9 -0
  29. package/lib/src/brand-page/utils/route.util.d.ts +1 -0
  30. package/lib/src/brand-page/utils/route.util.js +9 -0
  31. package/lib/src/brand-page/utils/view-model.util.js +53 -1
  32. package/lib/src/brand-page/view-models.d.ts +3 -1
  33. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +2 -1
  34. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +2 -1
  35. package/lib/src/brand-page-maker/stores/ai-checkpoint/ai-checkpoint-store.d.ts +1626 -0
  36. package/lib/src/brand-page-maker/validators/section-button.validator.d.ts +5 -2
  37. package/lib/src/brand-page-maker/validators/section-button.validator.js +11 -15
  38. package/lib/src/{bundled-translations-B2q5GWTQ.js → bundled-translations-KY0aSmMp.js} +9 -9
  39. package/lib/src/{bundled-translations.de-DE-FhH2H_rd.js → bundled-translations.de-DE-BIFi4bYj.js} +9 -9
  40. package/lib/src/{bundled-translations.es-ES-_ZExxMi7.js → bundled-translations.es-ES-CHlKuPLO.js} +5 -5
  41. package/lib/src/{bundled-translations.fr-CA-Tg-ODIiC.js → bundled-translations.fr-CA-DR1fvm8e.js} +1 -1
  42. package/lib/src/{bundled-translations.fr-FR-C0sU51Mz.js → bundled-translations.fr-FR-DnpWVIQg.js} +9 -9
  43. package/lib/src/{bundled-translations.pt-BR-MYFIhA4u.js → bundled-translations.pt-BR-p8szTOiT.js} +17 -17
  44. package/lib/src/{bundled-translations.pt-PT-DeUBSzCB.js → bundled-translations.pt-PT-DEk39NhW.js} +22 -22
  45. package/lib/src/bundles/bundled-translations.de-DE.json +6 -1
  46. package/lib/src/bundles/bundled-translations.es-ES.json +6 -1
  47. package/lib/src/bundles/bundled-translations.fr-CA.json +6 -1
  48. package/lib/src/bundles/bundled-translations.fr-FR.json +6 -1
  49. package/lib/src/bundles/bundled-translations.json +6 -1
  50. package/lib/src/bundles/bundled-translations.pt-BR.json +6 -1
  51. package/lib/src/bundles/bundled-translations.pt-PT.json +6 -1
  52. package/lib/src/css/library.brand-page-brandCrowd.min.css +573 -135
  53. package/lib/src/css/library.brand-page-brandPage.min.css +573 -135
  54. package/lib/src/css/library.brand-page-designCom.min.css +573 -135
  55. package/lib/src/css/library.brand-page-preview.min.css +573 -135
  56. package/lib/src/index.mjs +1 -1
  57. package/lib/src/{lib-BMVF4uNy.js → lib-B38VVCSw.js} +20304 -19843
  58. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +2 -1
  59. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +1 -1
  60. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +1 -1
  61. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +1 -1
  62. package/lib/src/shared/content-settings/components/text-field/TextField.mixin.d.ts +14 -2
  63. package/lib/src/shared/content-settings/components/text-field/TextField.mixin.js +14 -0
  64. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +1 -1
  65. package/lib/src/shared/page-navigation/store/page-navigation-store.d.ts +1 -0
  66. package/lib/src/shared/page-navigation/store/page-navigation-store.js +12 -0
  67. package/lib/src/shared/page-settings/components/delete-page-modal/DeletePageModal.mixin.d.ts +1 -1
  68. package/lib/src/shared/page-settings/components/new-page-modal/NewPageModal.mixin.d.ts +2 -1
  69. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +190 -1
  70. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +184 -2
  71. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.js +34 -2
  72. package/lib/src/shared/page-settings/store/page-settings-store.d.ts +2 -2
  73. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +3 -0
  74. package/package.json +2 -2
  75. package/src/brand-page/components/navigation/hamburger/HamburgerNavigation.vue +1 -0
  76. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.ts +18 -1
  77. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue +73 -3
  78. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue.test.ts +181 -0
  79. package/src/brand-page/components/page/Page.mixin.ts +21 -11
  80. package/src/brand-page/components/page/Page.vue +2 -0
  81. package/src/brand-page/components/page-header/HeaderLayout.stories.ts +10 -0
  82. package/src/brand-page/components/page-header/HeaderLayout.vue +415 -71
  83. package/src/brand-page/components/page-header/PageHeader.mixin.ts +14 -10
  84. package/src/brand-page/components/page-header/PageHeader.vue +16 -1
  85. package/src/brand-page/components/page-header/PageHeader.vue.test.ts +339 -0
  86. package/src/brand-page/constants/sections/header-display.constants.test.ts +44 -0
  87. package/src/brand-page/constants/sections/header-display.constants.ts +35 -0
  88. package/src/brand-page/models/header-button.model.test.ts +85 -0
  89. package/src/brand-page/models/index.ts +19 -0
  90. package/src/brand-page/utils/context.util.ts +15 -0
  91. package/src/brand-page/utils/route.util.ts +13 -0
  92. package/src/brand-page/utils/view-model.util.test.ts +151 -1
  93. package/src/brand-page/utils/view-model.util.ts +73 -1
  94. package/src/brand-page/view-models.ts +3 -0
  95. package/src/brand-page-maker/validators/section-button.validator.ts +23 -19
  96. package/src/bundles/bundled-translations.de-DE.json +6 -1
  97. package/src/bundles/bundled-translations.es-ES.json +6 -1
  98. package/src/bundles/bundled-translations.fr-CA.json +6 -1
  99. package/src/bundles/bundled-translations.fr-FR.json +6 -1
  100. package/src/bundles/bundled-translations.json +6 -1
  101. package/src/bundles/bundled-translations.pt-BR.json +6 -1
  102. package/src/bundles/bundled-translations.pt-PT.json +6 -1
  103. package/src/shared/content-settings/components/text-field/TextField.mixin.ts +15 -0
  104. package/src/shared/content-settings/components/url-field/UrlField.vue +1 -0
  105. package/src/shared/page-navigation/store/page-navigation-store.ts +16 -0
  106. package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.button-section.test.ts +131 -0
  107. package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.vue +65 -10
  108. package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.test.ts +305 -0
  109. package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.vue +265 -0
  110. package/src/shared/page-settings/components/page-settings-section-styles/HeaderDisplayStyle.test.ts +354 -0
  111. package/src/shared/page-settings/components/page-settings-section-styles/HeaderDisplayStyle.vue +48 -9
  112. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.header-button.test.ts +114 -0
  113. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.ts +38 -1
  114. package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.de-DE.json +6 -1
  115. package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.es-ES.json +6 -1
  116. package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.fr-CA.json +6 -1
  117. package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.fr-FR.json +6 -1
  118. package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.json +6 -1
  119. package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.pt-BR.json +6 -1
  120. package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.pt-PT.json +6 -1
  121. package/src/shared/page-settings/store/page-settings-store.ts +2 -0
@@ -1,8 +1,13 @@
1
1
  import { defineComponent } from 'vue';
2
2
  import type { PropType } from 'vue';
3
3
  import type { BrandPageContext } from '@brand-page/models';
4
- import type { PageHeaderViewModel } from '@brand-page/view-models';
4
+ import type {
5
+ ButtonViewModel,
6
+ PageHeaderViewModel,
7
+ } from '@brand-page/view-models';
5
8
  import { LOGO_SLIDER_DESKTOP_MAX } from '../../constants';
9
+ import { hasVisibleNavigation } from '../../utils/route.util';
10
+ import { guardPreviewClick } from '../../utils/context.util';
6
11
 
7
12
  export default defineComponent({
8
13
  props: {
@@ -37,16 +42,11 @@ export default defineComponent({
37
42
  padding: '1.5rem 0', // var(--page-padding) 0
38
43
  };
39
44
  },
45
+ headerButtonViewModel(): ButtonViewModel | undefined {
46
+ return this.viewModel.headerButtonViewModel;
47
+ },
40
48
  showNav(): boolean {
41
- const visibleNavItems = this.context.nav.navItems.filter(
42
- (item) => !item.isHidden,
43
- );
44
-
45
- return (
46
- visibleNavItems.length > 1 ||
47
- (visibleNavItems.length === 1 &&
48
- visibleNavItems[0].nestedNavItems.some((item) => !item.isHidden))
49
- );
49
+ return hasVisibleNavigation(this.context.nav.navItems);
50
50
  },
51
51
  },
52
52
  emits: ['header-container-click'],
@@ -54,5 +54,9 @@ export default defineComponent({
54
54
  onHeaderContainerClick() {
55
55
  this.$emit('header-container-click');
56
56
  },
57
+ // Preview only: deliberately inert — no link-follow, no Header Settings open.
58
+ onHeaderCtaClick(event: MouseEvent): void {
59
+ guardPreviewClick(event, this.context);
60
+ },
57
61
  },
58
62
  });
@@ -1,5 +1,8 @@
1
1
  <template>
2
- <div v-if="showNav || viewModel.logo.isVisible" :style="containerCss">
2
+ <div
3
+ v-if="showNav || viewModel.logo.isVisible || headerButtonViewModel"
4
+ :style="containerCss"
5
+ >
3
6
  <div class="c-page-header-container" @click="onHeaderContainerClick()">
4
7
  <HeaderLayout
5
8
  :nav-id="navId"
@@ -8,6 +11,7 @@
8
11
  :logo-width="
9
12
  viewModel.logo?.logoImageWidth || LOGO_SLIDER_DESKTOP_MAX + 'rem'
10
13
  "
14
+ :has-cta="Boolean(headerButtonViewModel)"
11
15
  >
12
16
  <template #logo>
13
17
  <Logo
@@ -29,6 +33,15 @@
29
33
  @click.stop
30
34
  />
31
35
  </template>
36
+ <template v-if="headerButtonViewModel" #cta>
37
+ <!-- Test hook is per-instance (nav-1/nav-2/side menu) since both viewport headers stay in the DOM. -->
38
+ <Button
39
+ :data-test-header-cta-button="navId"
40
+ :view-model="headerButtonViewModel"
41
+ :context="context"
42
+ @click.capture="onHeaderCtaClick"
43
+ />
44
+ </template>
32
45
  </HeaderLayout>
33
46
  </div>
34
47
  </div>
@@ -38,12 +51,14 @@ import PageHeaderMixin from './PageHeader.mixin.ts';
38
51
  import Logo from '../logo/Logo.vue';
39
52
  import Navigation from '../navigation/Navigation.vue';
40
53
  import HeaderLayout from './HeaderLayout.vue';
54
+ import Button from '../button/Button.vue';
41
55
 
42
56
  export default {
43
57
  components: {
44
58
  Navigation,
45
59
  Logo,
46
60
  HeaderLayout,
61
+ Button,
47
62
  },
48
63
  mixins: [PageHeaderMixin],
49
64
  emits: ['open-side-menu'],
@@ -0,0 +1,339 @@
1
+ import { vi } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import { createRouter, createMemoryHistory } from 'vue-router';
4
+ import { createPinia, setActivePinia } from 'pinia';
5
+ import PageHeader from './PageHeader.vue';
6
+ import {
7
+ BRAND_PAGE_TYPES,
8
+ BUTTON_LINK_TYPE,
9
+ MEDIA_SIZES,
10
+ PageHeaderDesktop,
11
+ PageHeaderMobile,
12
+ PageStyles,
13
+ type BrandPageContext,
14
+ type HeaderButton,
15
+ } from '@brand-page/models';
16
+ import {
17
+ HEADER_DISPLAY,
18
+ type HeaderDisplayStyle,
19
+ } from '@src/brand-page/constants';
20
+ import type { NavItemViewModel } from '@brand-page/view-models';
21
+ import { mapToPageHeaderViewModel } from '@brand-page/utils';
22
+
23
+ // NavigationCarousel calls `new ResizeObserver(...)`, so this has to be constructible.
24
+ global.ResizeObserver = class {
25
+ observe = vi.fn();
26
+ unobserve = vi.fn();
27
+ disconnect = vi.fn();
28
+ } as unknown as typeof ResizeObserver;
29
+
30
+ const CTA = '[data-test-header-cta-button]';
31
+ const SLUG = 'my-brand';
32
+
33
+ const navItems = (): NavItemViewModel[] =>
34
+ [
35
+ {
36
+ pageId: 'home',
37
+ slug: SLUG,
38
+ displayName: 'Home',
39
+ routeName: 'home-route',
40
+ routeParams: { slug: SLUG },
41
+ isRoot: true,
42
+ isHidden: false,
43
+ relativePath: '',
44
+ nestedNavItems: [],
45
+ },
46
+ {
47
+ pageId: 'page-2',
48
+ slug: SLUG,
49
+ displayName: 'Second Page',
50
+ routeName: 'page-route',
51
+ routeParams: { slug: SLUG, pageSlug: 'second-page' },
52
+ isRoot: false,
53
+ isHidden: false,
54
+ relativePath: 'second-page',
55
+ nestedNavItems: [],
56
+ },
57
+ ] as NavItemViewModel[];
58
+
59
+ const createContext = (): BrandPageContext =>
60
+ ({
61
+ brandPageSlug: SLUG,
62
+ brandPageToken: '',
63
+ isMobile: false,
64
+ isPreview: false,
65
+ isProduction: false,
66
+ isServerRender: false,
67
+ options: { mediaSizes: MEDIA_SIZES.sizeThumb },
68
+ isTemplate: false,
69
+ isDesignCom: false,
70
+ pageTitle: 'My Brand',
71
+ runtimeEnvironment: '',
72
+ stripePublishableKey: undefined,
73
+ recaptchaSiteKey: undefined,
74
+ customDomain: undefined,
75
+ brandPageType: BRAND_PAGE_TYPES.brandSite,
76
+ isBrandPage: false,
77
+ isBrandPageOrIsBrandContact: false,
78
+ isBrandSite: true,
79
+ isBrandContact: false,
80
+ pages: [],
81
+ isCapturingPreview: false,
82
+ nav: { navItems: navItems() },
83
+ }) as BrandPageContext;
84
+
85
+ const aHeaderButton = (
86
+ overrides: Partial<HeaderButton> = {},
87
+ ): HeaderButton => ({
88
+ isVisible: true,
89
+ text: 'Book now',
90
+ buttonLinkType: BUTTON_LINK_TYPE.url,
91
+ url: 'https://example.com/book',
92
+ buttonTextStyle: { textStyleType: 'buttons' },
93
+ ...overrides,
94
+ });
95
+
96
+ const mountHeader = async (options: {
97
+ headerButton?: HeaderButton;
98
+ displayStyle?: HeaderDisplayStyle;
99
+ isLogoVisible?: boolean;
100
+ navItems?: NavItemViewModel[];
101
+ isMobile?: boolean;
102
+ isPreview?: boolean;
103
+ }) => {
104
+ const router = createRouter({
105
+ history: createMemoryHistory(),
106
+ routes: [
107
+ {
108
+ path: '/:slug/:pageSlug',
109
+ name: 'page-route',
110
+ component: { template: '<div />' },
111
+ },
112
+ {
113
+ path: '/:slug',
114
+ name: 'home-route',
115
+ component: { template: '<div />' },
116
+ },
117
+ ],
118
+ });
119
+
120
+ router.push(`/${SLUG}`);
121
+ await router.isReady();
122
+
123
+ const HeaderModel = options.isMobile ? PageHeaderMobile : PageHeaderDesktop;
124
+ const context = createContext();
125
+
126
+ if (options.navItems) {
127
+ context.nav.navItems = options.navItems;
128
+ }
129
+
130
+ if (options.isPreview) {
131
+ context.isPreview = true;
132
+ }
133
+
134
+ const viewModel = mapToPageHeaderViewModel(
135
+ new HeaderModel({
136
+ headerButton: options.headerButton,
137
+ isLogoVisible: options.isLogoVisible ?? true,
138
+ ...(options.displayStyle ? { displayStyle: options.displayStyle } : {}),
139
+ }),
140
+ new PageStyles(),
141
+ [],
142
+ );
143
+
144
+ return mount(PageHeader, {
145
+ propsData: { navId: 'nav-1', viewModel, context },
146
+ global: { plugins: [router] },
147
+ });
148
+ };
149
+
150
+ describe('PageHeader - header call-to-action button (BP-5852)', () => {
151
+ beforeEach(() => {
152
+ setActivePinia(createPinia());
153
+ });
154
+
155
+ describe('visibility', () => {
156
+ test('renders no CTA when the header has no button', async () => {
157
+ const wrapper = await mountHeader({});
158
+
159
+ expect(wrapper.find(CTA).exists()).toBe(false);
160
+ expect(wrapper.find('[data-test-header-layout-cta]').exists()).toBe(
161
+ false,
162
+ );
163
+ });
164
+
165
+ test('renders the CTA when the button is visible', async () => {
166
+ const wrapper = await mountHeader({ headerButton: aHeaderButton() });
167
+
168
+ expect(wrapper.find(CTA).exists()).toBe(true);
169
+ expect(wrapper.get(CTA).text()).toContain('Book now');
170
+ });
171
+
172
+ // Per-style row-vs-side menu is pinned in header-display.constants.test.ts; this just proves the wiring.
173
+ test('a side menu-classified style keeps the CTA out of the header row', async () => {
174
+ const wrapper = await mountHeader({
175
+ headerButton: aHeaderButton(),
176
+ displayStyle: HEADER_DISPLAY.MOBILE_HAMBURGER_1,
177
+ isMobile: true,
178
+ });
179
+
180
+ expect(wrapper.find(CTA).exists()).toBe(false);
181
+ });
182
+
183
+ test('renders the CTA in the mobile hamburger row when the nav is hidden (single page: no icon, no side menu)', async () => {
184
+ const wrapper = await mountHeader({
185
+ headerButton: aHeaderButton(),
186
+ displayStyle: HEADER_DISPLAY.MOBILE_HAMBURGER_1,
187
+ isMobile: true,
188
+ navItems: [navItems()[0]],
189
+ });
190
+
191
+ expect(wrapper.find(CTA).exists()).toBe(true);
192
+ });
193
+ });
194
+
195
+ describe('render gate', () => {
196
+ // A visible button must bring the header back even when nav and logo are hidden.
197
+ const singlePageNav = () => [navItems()[0]];
198
+
199
+ test('renders no header when there is no nav, no logo and no button', async () => {
200
+ const wrapper = await mountHeader({
201
+ isLogoVisible: false,
202
+ navItems: singlePageNav(),
203
+ });
204
+
205
+ expect(wrapper.find('[data-component="header-layout"]').exists()).toBe(
206
+ false,
207
+ );
208
+ });
209
+
210
+ test('renders the header when only the button is visible', async () => {
211
+ const wrapper = await mountHeader({
212
+ headerButton: aHeaderButton(),
213
+ isLogoVisible: false,
214
+ navItems: singlePageNav(),
215
+ });
216
+
217
+ expect(wrapper.find('[data-component="header-layout"]').exists()).toBe(
218
+ true,
219
+ );
220
+ expect(wrapper.find(CTA).exists()).toBe(true);
221
+ });
222
+ });
223
+
224
+ describe('layout hooks', () => {
225
+ test('flags the layout with has-header-cta only while a CTA renders', async () => {
226
+ const withButton = await mountHeader({
227
+ headerButton: aHeaderButton(),
228
+ });
229
+ const without = await mountHeader({});
230
+
231
+ expect(
232
+ withButton.get('[data-component="header-layout"]').classes(),
233
+ ).toContain('has-header-cta');
234
+ expect(
235
+ without.get('[data-component="header-layout"]').classes(),
236
+ ).not.toContain('has-header-cta');
237
+ });
238
+ });
239
+
240
+ describe('link kinds', () => {
241
+ test('renders an external url as a new-tab anchor', async () => {
242
+ const wrapper = await mountHeader({
243
+ headerButton: aHeaderButton({ url: 'https://example.com/book' }),
244
+ });
245
+
246
+ const anchor = wrapper.get(`${CTA} a`);
247
+
248
+ expect(anchor.attributes('href')).toBe('https://example.com/book');
249
+ // target/rel are the external-anchor branch; the router-link branch has neither.
250
+ expect(anchor.attributes('target')).toBe('_blank');
251
+ expect(anchor.attributes('rel')).toBe('noopener');
252
+ });
253
+
254
+ test('renders a picked page as an in-app router link', async () => {
255
+ const wrapper = await mountHeader({
256
+ headerButton: aHeaderButton({
257
+ buttonLinkType: BUTTON_LINK_TYPE.page,
258
+ buttonPageId: 'page-2',
259
+ url: undefined,
260
+ }),
261
+ });
262
+
263
+ const anchor = wrapper.get(`${CTA} a`);
264
+
265
+ expect(anchor.attributes('href')).toBe(`/${SLUG}/second-page`);
266
+ expect(anchor.attributes('target')).toBeUndefined();
267
+ });
268
+
269
+ test('renders an email address as a mailto link', async () => {
270
+ const wrapper = await mountHeader({
271
+ headerButton: aHeaderButton({
272
+ buttonLinkType: BUTTON_LINK_TYPE.mailTo,
273
+ url: 'hi@example.com',
274
+ }),
275
+ });
276
+
277
+ expect(wrapper.get(`${CTA} a`).attributes('href')).toBe(
278
+ 'mailto:hi@example.com',
279
+ );
280
+ });
281
+
282
+ test('renders a phone number as a tel link', async () => {
283
+ const wrapper = await mountHeader({
284
+ headerButton: aHeaderButton({
285
+ buttonLinkType: BUTTON_LINK_TYPE.tel,
286
+ url: '+61400000000',
287
+ }),
288
+ });
289
+
290
+ expect(wrapper.get(`${CTA} a`).attributes('href')).toBe(
291
+ 'tel:+61400000000',
292
+ );
293
+ });
294
+ });
295
+
296
+ describe('maker click guard', () => {
297
+ // In the maker preview the button must neither follow its link nor open the header panel.
298
+ test('a preview CTA click neither bubbles to the header container nor follows the link', async () => {
299
+ const wrapper = await mountHeader({
300
+ headerButton: aHeaderButton(),
301
+ isPreview: true,
302
+ });
303
+
304
+ const event = new MouseEvent('click', {
305
+ bubbles: true,
306
+ cancelable: true,
307
+ });
308
+
309
+ wrapper.get(`${CTA} a`).element.dispatchEvent(event);
310
+
311
+ expect(event.defaultPrevented).toBe(true);
312
+ expect(wrapper.emitted('header-container-click')).toBeUndefined();
313
+ });
314
+
315
+ test('a published CTA click follows the link (default not prevented)', async () => {
316
+ const wrapper = await mountHeader({ headerButton: aHeaderButton() });
317
+
318
+ const event = new MouseEvent('click', {
319
+ bubbles: true,
320
+ cancelable: true,
321
+ });
322
+
323
+ wrapper.get(`${CTA} a`).element.dispatchEvent(event);
324
+
325
+ expect(event.defaultPrevented).toBe(false);
326
+ });
327
+
328
+ test('still reports a click on the header container itself in preview', async () => {
329
+ const wrapper = await mountHeader({
330
+ headerButton: aHeaderButton(),
331
+ isPreview: true,
332
+ });
333
+
334
+ await wrapper.get('.c-page-header-container').trigger('click');
335
+
336
+ expect(wrapper.emitted('header-container-click')).toHaveLength(1);
337
+ });
338
+ });
339
+ });
@@ -0,0 +1,44 @@
1
+ import {
2
+ HEADER_DISPLAY,
3
+ headerRowRendersCta,
4
+ } from './header-display.constants';
5
+
6
+ // One predicate decides row vs side menu, so the button can never show in both.
7
+ describe('headerRowRendersCta', () => {
8
+ test.each([
9
+ HEADER_DISPLAY.MOBILE_HAMBURGER_1,
10
+ HEADER_DISPLAY.MOBILE_HAMBURGER_2,
11
+ HEADER_DISPLAY.MOBILE_HAMBURGER_3,
12
+ HEADER_DISPLAY.MOBILE_HAMBURGER_4,
13
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_1,
14
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_2,
15
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_3,
16
+ ])('%s with a visible nav: the side menu carries the CTA', (displayStyle) => {
17
+ expect(headerRowRendersCta(displayStyle, true)).toBe(false);
18
+ });
19
+
20
+ test.each([
21
+ HEADER_DISPLAY.MOBILE_HAMBURGER_1,
22
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_1,
23
+ ])(
24
+ '%s with the nav hidden: no side menu exists, the row carries it',
25
+ (displayStyle) => {
26
+ expect(headerRowRendersCta(displayStyle, false)).toBe(true);
27
+ },
28
+ );
29
+
30
+ test.each([
31
+ HEADER_DISPLAY.DESKTOP_HAMBURGER_1,
32
+ HEADER_DISPLAY.DESKTOP_HAMBURGER_2,
33
+ HEADER_DISPLAY.DESKTOP_TAB_1,
34
+ HEADER_DISPLAY.MOBILE_TAB_1,
35
+ HEADER_DISPLAY.MOBILE_TAB_2,
36
+ HEADER_DISPLAY.MOBILE_TAB_NO_LOGO_1,
37
+ ])('%s: the row carries the CTA', (displayStyle) => {
38
+ expect(headerRowRendersCta(displayStyle, true)).toBe(true);
39
+ });
40
+
41
+ test('an unknown/deprecated display style defaults to the row', () => {
42
+ expect(headerRowRendersCta('align-left', true)).toBe(true);
43
+ });
44
+ });
@@ -58,6 +58,8 @@ interface HeaderDisplayStyleConfig {
58
58
  supportLogo: boolean;
59
59
  supportedNavTypes: string[]; // NAVIGATION_DISPLAY
60
60
  option: ButtonGroupOption;
61
+ // Tile artwork that includes the header button; only styles with drawn variants set it.
62
+ withButtonIconName?: string;
61
63
  }
62
64
 
63
65
  export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
@@ -74,6 +76,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
74
76
  dataAttribute: HEADER_DISPLAY.DESKTOP_TAB_1,
75
77
  isSelected: false,
76
78
  },
79
+ withButtonIconName:
80
+ 'header-desktop-nav-style-tab-display-style-with-button-1',
77
81
  },
78
82
  {
79
83
  viewMode: 'desktop',
@@ -87,6 +91,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
87
91
  dataAttribute: HEADER_DISPLAY.DESKTOP_TAB_2,
88
92
  isSelected: false,
89
93
  },
94
+ withButtonIconName:
95
+ 'header-desktop-nav-style-tab-display-style-with-button-2',
90
96
  },
91
97
  {
92
98
  viewMode: 'desktop',
@@ -100,6 +106,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
100
106
  dataAttribute: HEADER_DISPLAY.DESKTOP_TAB_3,
101
107
  isSelected: false,
102
108
  },
109
+ withButtonIconName:
110
+ 'header-desktop-nav-style-tab-display-style-with-button-3',
103
111
  },
104
112
  {
105
113
  viewMode: 'desktop',
@@ -113,6 +121,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
113
121
  dataAttribute: HEADER_DISPLAY.DESKTOP_TAB_4,
114
122
  isSelected: false,
115
123
  },
124
+ withButtonIconName:
125
+ 'header-desktop-nav-style-tab-display-style-with-button-4',
116
126
  },
117
127
  {
118
128
  viewMode: 'desktop',
@@ -126,6 +136,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
126
136
  dataAttribute: HEADER_DISPLAY.DESKTOP_TAB_5,
127
137
  isSelected: false,
128
138
  },
139
+ withButtonIconName:
140
+ 'header-desktop-nav-style-tab-display-style-with-button-5',
129
141
  },
130
142
  {
131
143
  viewMode: 'desktop',
@@ -139,6 +151,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
139
151
  dataAttribute: HEADER_DISPLAY.DESKTOP_TAB_6,
140
152
  isSelected: false,
141
153
  },
154
+ withButtonIconName:
155
+ 'header-desktop-nav-style-tab-display-style-with-button-6',
142
156
  },
143
157
  // desktop - tab with no logo
144
158
  {
@@ -198,6 +212,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
198
212
  dataAttribute: HEADER_DISPLAY.DESKTOP_HAMBURGER_1,
199
213
  isSelected: false,
200
214
  },
215
+ withButtonIconName:
216
+ 'header-desktop-nav-style-hamburger-display-style-with-button-1',
201
217
  },
202
218
  {
203
219
  viewMode: 'desktop',
@@ -216,6 +232,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
216
232
  dataAttribute: HEADER_DISPLAY.DESKTOP_HAMBURGER_2,
217
233
  isSelected: false,
218
234
  },
235
+ withButtonIconName:
236
+ 'header-desktop-nav-style-hamburger-display-style-with-button-2',
219
237
  },
220
238
  // desktop - hamburger no logo
221
239
  {
@@ -285,6 +303,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
285
303
  dataAttribute: HEADER_DISPLAY.MOBILE_TAB_1,
286
304
  isSelected: false,
287
305
  },
306
+ withButtonIconName:
307
+ 'header-mobile-nav-style-tab-display-style-with-button-1',
288
308
  },
289
309
  {
290
310
  viewMode: 'mobile',
@@ -298,6 +318,8 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
298
318
  dataAttribute: HEADER_DISPLAY.MOBILE_TAB_2,
299
319
  isSelected: false,
300
320
  },
321
+ withButtonIconName:
322
+ 'header-mobile-nav-style-tab-display-style-with-button-2',
301
323
  },
302
324
  // mobile - tab with no logo
303
325
  {
@@ -442,3 +464,16 @@ export const HEADER_DISPLAY_STYLE_CONFIGS: HeaderDisplayStyleConfig[] = [
442
464
  },
443
465
  },
444
466
  ];
467
+
468
+ /** True when the header row shows the button; mobile hamburger styles put it in the side menu instead (unless the nav is hidden — then there is no side menu). */
469
+ export const headerRowRendersCta = (
470
+ displayStyle: string,
471
+ showNav: boolean,
472
+ ): boolean =>
473
+ !showNav ||
474
+ !HEADER_DISPLAY_STYLE_CONFIGS.some(
475
+ (config) =>
476
+ config.option.value === displayStyle &&
477
+ config.viewMode === 'mobile' &&
478
+ config.navType === 'hamburger',
479
+ );
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import {
3
+ BrandPageModel,
4
+ BUTTON_LINK_TYPE,
5
+ PageHeaderDesktop,
6
+ PageHeaderMobile,
7
+ type HeaderButton,
8
+ } from './index';
9
+
10
+ const aHeaderButton = (): HeaderButton => ({
11
+ isVisible: true,
12
+ text: 'Contact Us',
13
+ buttonLinkType: BUTTON_LINK_TYPE.url,
14
+ url: 'https://example.com',
15
+ buttonTextStyle: { textStyleType: 'buttons' },
16
+ fillColor: '#123456',
17
+ borderColor: '#654321',
18
+ });
19
+
20
+ // The constructors copy fields one-by-one — a forgotten field is silently dropped on load.
21
+ describe('header button persistence', () => {
22
+ describe('PageHeader* constructor', () => {
23
+ test('keeps a persisted headerButton on both viewports', () => {
24
+ expect(
25
+ new PageHeaderDesktop({ headerButton: aHeaderButton() }).headerButton,
26
+ ).toEqual(aHeaderButton());
27
+ expect(
28
+ new PageHeaderMobile({ headerButton: aHeaderButton() }).headerButton,
29
+ ).toEqual(aHeaderButton());
30
+ });
31
+
32
+ test('leaves headerButton unset when absent (no default seeded)', () => {
33
+ expect(new PageHeaderDesktop().headerButton).toBeUndefined();
34
+ expect(new PageHeaderMobile().headerButton).toBeUndefined();
35
+ });
36
+
37
+ test('keeps a page link button with buttonPageId', () => {
38
+ const button: HeaderButton = {
39
+ isVisible: true,
40
+ text: 'About',
41
+ buttonLinkType: BUTTON_LINK_TYPE.page,
42
+ buttonPageId: 'page-2',
43
+ };
44
+
45
+ expect(
46
+ new PageHeaderDesktop({ headerButton: button }).headerButton,
47
+ ).toEqual(button);
48
+ });
49
+ });
50
+
51
+ describe('BrandPageModel round-trip', () => {
52
+ test('survives deserialization from persisted JSON on both viewports', () => {
53
+ const model = new BrandPageModel({
54
+ styles: {},
55
+ pages: [],
56
+ pageHeaderDesktop: { headerButton: aHeaderButton() },
57
+ pageHeaderMobile: { headerButton: aHeaderButton() },
58
+ });
59
+
60
+ expect(model.pageHeaderDesktop.headerButton).toEqual(aHeaderButton());
61
+ expect(model.pageHeaderMobile.headerButton).toEqual(aHeaderButton());
62
+ });
63
+
64
+ test('survives a second pass through the model (re-serialize then re-hydrate)', () => {
65
+ const first = new BrandPageModel({
66
+ styles: {},
67
+ pages: [],
68
+ pageHeaderDesktop: { headerButton: aHeaderButton() },
69
+ pageHeaderMobile: { headerButton: aHeaderButton() },
70
+ });
71
+
72
+ const second = new BrandPageModel(JSON.parse(JSON.stringify(first)));
73
+
74
+ expect(second.pageHeaderDesktop.headerButton).toEqual(aHeaderButton());
75
+ expect(second.pageHeaderMobile.headerButton).toEqual(aHeaderButton());
76
+ });
77
+
78
+ test('an existing site with no headerButton stays without one', () => {
79
+ const model = new BrandPageModel({ styles: {}, pages: [] });
80
+
81
+ expect(model.pageHeaderDesktop.headerButton).toBeUndefined();
82
+ expect(model.pageHeaderMobile.headerButton).toBeUndefined();
83
+ });
84
+ });
85
+ });