@designcrowd/library.brand-page 6.1.16 → 6.1.17-ast-header-button

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 (85) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-CmNhDMIf.js → CreatedOnBrandCrowd-de-de-Cwq9enJs.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-DgplxxWa.js → CreatedOnBrandCrowd-en-us-ILK04gFj.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-DFdhFOa1.js → CreatedOnBrandCrowd-es-es-CbjxLxtu.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-DjLCWBLB.js → CreatedOnBrandCrowd-fr-ca-BwBDiK9z.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-DjLCWBLB.js → CreatedOnBrandCrowd-fr-fr-BwBDiK9z.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-CI2XD0OS.js → CreatedOnBrandCrowd-pt-br-DvhJROpG.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-CI2XD0OS.js → CreatedOnBrandCrowd-pt-pt-DvhJROpG.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-CkLkBztx.js → CreatedOnDcom-de-de-BK9imWOh.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-frnxBJJv.js → CreatedOnDcom-en-us-CyH-_qz1.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CrqgiERj.js → CreatedOnDcom-es-es-Dh51qYEA.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-CPoEcusj.js → CreatedOnDcom-fr-ca-BArRxwWX.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-BqrRH3dV.js → CreatedOnDcom-fr-fr-CdglI-ZB.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-CpbVi3Sw.js → CreatedOnDcom-pt-br-DCehns-G.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-s0XZ3c7y.js → CreatedOnDcom-pt-pt-B8hl-oew.js} +1 -1
  15. package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.d.ts +27 -2
  16. package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.js +26 -0
  17. package/lib/src/brand-page/components/page-header/PageHeader.mixin.d.ts +3 -1
  18. package/lib/src/brand-page/components/page-header/PageHeader.mixin.js +12 -0
  19. package/lib/src/brand-page/models/index.d.ts +12 -0
  20. package/lib/src/brand-page/models/index.js +3 -0
  21. package/lib/src/brand-page/utils/button.util.d.ts +5 -0
  22. package/lib/src/brand-page/utils/button.util.js +4 -0
  23. package/lib/src/brand-page/utils/view-model.util.d.ts +1 -0
  24. package/lib/src/brand-page/utils/view-model.util.js +51 -1
  25. package/lib/src/brand-page/view-models.d.ts +3 -1
  26. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +1 -1
  27. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +1 -1
  28. package/lib/src/brand-page-maker/stores/ai-checkpoint/ai-checkpoint-store.d.ts +1008 -0
  29. package/lib/src/{bundled-translations-B2q5GWTQ.js → bundled-translations-B8fAEel0.js} +1 -1
  30. package/lib/src/bundles/bundled-translations.json +7 -1
  31. package/lib/src/css/library.brand-page-brandCrowd.min.css +315 -122
  32. package/lib/src/css/library.brand-page-brandPage.min.css +315 -122
  33. package/lib/src/css/library.brand-page-designCom.min.css +315 -122
  34. package/lib/src/css/library.brand-page-preview.min.css +315 -122
  35. package/lib/src/index.d.ts +1 -0
  36. package/lib/src/index.mjs +45 -44
  37. package/lib/src/{lib-O_eJgJHP.js → lib-KFjPFfxn.js} +31522 -31118
  38. package/lib/src/lib.js +1 -0
  39. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +1 -1
  40. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +1 -1
  41. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +1 -1
  42. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +1 -1
  43. package/lib/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.d.ts +11 -0
  44. package/lib/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.js +80 -0
  45. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +1 -1
  46. package/lib/src/shared/page-settings/components/delete-page-modal/DeletePageModal.mixin.d.ts +1 -1
  47. package/lib/src/shared/page-settings/components/new-page-modal/NewPageModal.mixin.d.ts +1 -1
  48. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +113 -1
  49. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +117 -2
  50. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.js +43 -1
  51. package/lib/src/shared/page-settings/store/page-settings-store.d.ts +2 -2
  52. package/package.json +1 -1
  53. package/src/brand-page/components/button/Button.vue +35 -8
  54. package/src/brand-page/components/button/Button.vue.test.ts +59 -0
  55. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.ts +29 -1
  56. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue +36 -0
  57. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue.test.ts +175 -0
  58. package/src/brand-page/components/page/Page.vue +6 -0
  59. package/src/brand-page/components/page-header/HeaderLayout.vue +198 -4
  60. package/src/brand-page/components/page-header/PageHeader.mixin.ts +16 -1
  61. package/src/brand-page/components/page-header/PageHeader.vue +23 -1
  62. package/src/brand-page/components/page-header/PageHeader.vue.test.ts +378 -0
  63. package/src/brand-page/models/header-button.model.test.ts +86 -0
  64. package/src/brand-page/models/index.ts +16 -0
  65. package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +6 -0
  66. package/src/brand-page/utils/__snapshots__/migration2.util.test.ts.snap +2 -0
  67. package/src/brand-page/utils/__snapshots__/model.util.test.ts.snap +4 -0
  68. package/src/brand-page/utils/button.util.ts +8 -0
  69. package/src/brand-page/utils/view-model.util.test.ts +146 -1
  70. package/src/brand-page/utils/view-model.util.ts +71 -1
  71. package/src/brand-page/view-models.ts +3 -0
  72. package/src/bundles/bundled-translations.json +7 -1
  73. package/src/lib.ts +1 -0
  74. package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.test.ts +399 -0
  75. package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.ts +99 -0
  76. package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.button-section.test.ts +126 -0
  77. package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.vue +43 -6
  78. package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.test.ts +167 -0
  79. package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.vue +237 -0
  80. package/src/shared/page-settings/components/page-settings-section-styles/HeaderDisplayStyle.test.ts +160 -0
  81. package/src/shared/page-settings/components/page-settings-section-styles/HeaderDisplayStyle.vue +37 -8
  82. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.header-button.test.ts +113 -0
  83. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.ts +47 -0
  84. package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.json +7 -1
  85. package/src/shared/page-settings/store/page-settings-store.ts +2 -0
@@ -0,0 +1,378 @@
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(
161
+ wrapper.find('[data-component="header-layout-cta"]').exists(),
162
+ ).toBe(false);
163
+ });
164
+
165
+ test('renders no CTA when the button is configured but hidden', async () => {
166
+ const wrapper = await mountHeader({
167
+ headerButton: aHeaderButton({ isVisible: false }),
168
+ });
169
+
170
+ expect(wrapper.find(CTA).exists()).toBe(false);
171
+ });
172
+
173
+ test('renders the CTA when the button is visible', async () => {
174
+ const wrapper = await mountHeader({ headerButton: aHeaderButton() });
175
+
176
+ expect(wrapper.find(CTA).exists()).toBe(true);
177
+ expect(wrapper.get(CTA).text()).toContain('Book now');
178
+ });
179
+
180
+ test('falls back to "Contact Us" when the label is empty', async () => {
181
+ const wrapper = await mountHeader({
182
+ headerButton: aHeaderButton({ text: '' }),
183
+ });
184
+
185
+ expect(wrapper.get(CTA).text()).toContain('Contact Us');
186
+ });
187
+
188
+ // The header row is CTA-free on the mobile hamburger styles — the drawer carries
189
+ // the button there (NavigationSideMenu).
190
+ test.each([
191
+ HEADER_DISPLAY.MOBILE_HAMBURGER_1,
192
+ HEADER_DISPLAY.MOBILE_HAMBURGER_2,
193
+ HEADER_DISPLAY.MOBILE_HAMBURGER_3,
194
+ HEADER_DISPLAY.MOBILE_HAMBURGER_4,
195
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_1,
196
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_2,
197
+ HEADER_DISPLAY.MOBILE_HAMBURGER_NO_LOGO_3,
198
+ ])('renders no CTA in the %s header row', async (displayStyle) => {
199
+ const wrapper = await mountHeader({
200
+ headerButton: aHeaderButton(),
201
+ displayStyle,
202
+ isMobile: true,
203
+ });
204
+
205
+ expect(wrapper.find(CTA).exists()).toBe(false);
206
+ });
207
+
208
+ test.each([
209
+ HEADER_DISPLAY.DESKTOP_HAMBURGER_1,
210
+ HEADER_DISPLAY.DESKTOP_HAMBURGER_2,
211
+ ])('renders the CTA in the %s header row', async (displayStyle) => {
212
+ const wrapper = await mountHeader({
213
+ headerButton: aHeaderButton(),
214
+ displayStyle,
215
+ });
216
+
217
+ expect(wrapper.find(CTA).exists()).toBe(true);
218
+ });
219
+
220
+ test('renders the CTA in the mobile hamburger row when the nav is hidden (single page: no icon, no drawer)', async () => {
221
+ const wrapper = await mountHeader({
222
+ headerButton: aHeaderButton(),
223
+ displayStyle: HEADER_DISPLAY.MOBILE_HAMBURGER_1,
224
+ isMobile: true,
225
+ navItems: [navItems()[0]],
226
+ });
227
+
228
+ expect(wrapper.find(CTA).exists()).toBe(true);
229
+ });
230
+ });
231
+
232
+ describe('render gate', () => {
233
+ // A single-page site with a hidden logo renders no header at all today; a visible
234
+ // CTA has to bring the header wrapper back.
235
+ const singlePageNav = () => [navItems()[0]];
236
+
237
+ test('renders no header when there is no nav, no logo and no button', async () => {
238
+ const wrapper = await mountHeader({
239
+ isLogoVisible: false,
240
+ navItems: singlePageNav(),
241
+ });
242
+
243
+ expect(wrapper.find('[data-component="header-layout"]').exists()).toBe(
244
+ false,
245
+ );
246
+ });
247
+
248
+ test('renders the header when only the button is visible', async () => {
249
+ const wrapper = await mountHeader({
250
+ headerButton: aHeaderButton(),
251
+ isLogoVisible: false,
252
+ navItems: singlePageNav(),
253
+ });
254
+
255
+ expect(wrapper.find('[data-component="header-layout"]').exists()).toBe(
256
+ true,
257
+ );
258
+ expect(wrapper.find(CTA).exists()).toBe(true);
259
+ });
260
+ });
261
+
262
+ describe('layout hooks', () => {
263
+ test('flags the layout with has-header-cta only while a CTA renders', async () => {
264
+ const withButton = await mountHeader({
265
+ headerButton: aHeaderButton(),
266
+ });
267
+ const without = await mountHeader({});
268
+
269
+ expect(
270
+ withButton.get('[data-component="header-layout"]').classes(),
271
+ ).toContain('has-header-cta');
272
+ expect(
273
+ without.get('[data-component="header-layout"]').classes(),
274
+ ).not.toContain('has-header-cta');
275
+ });
276
+ });
277
+
278
+ describe('link kinds', () => {
279
+ test('renders an external url as a new-tab anchor', async () => {
280
+ const wrapper = await mountHeader({
281
+ headerButton: aHeaderButton({ url: 'https://example.com/book' }),
282
+ });
283
+
284
+ const anchor = wrapper.get(`${CTA} a`);
285
+
286
+ expect(anchor.attributes('href')).toBe('https://example.com/book');
287
+ // target/rel are the external-anchor branch; the router-link branch has neither.
288
+ expect(anchor.attributes('target')).toBe('_blank');
289
+ expect(anchor.attributes('rel')).toBe('noopener');
290
+ });
291
+
292
+ test('renders a picked page as an in-app router link', async () => {
293
+ const wrapper = await mountHeader({
294
+ headerButton: aHeaderButton({
295
+ buttonLinkType: BUTTON_LINK_TYPE.page,
296
+ buttonPageId: 'page-2',
297
+ url: undefined,
298
+ }),
299
+ });
300
+
301
+ const anchor = wrapper.get(`${CTA} a`);
302
+
303
+ expect(anchor.attributes('href')).toBe(`/${SLUG}/second-page`);
304
+ expect(anchor.attributes('target')).toBeUndefined();
305
+ });
306
+
307
+ test('renders an email address as a mailto link', async () => {
308
+ const wrapper = await mountHeader({
309
+ headerButton: aHeaderButton({
310
+ buttonLinkType: BUTTON_LINK_TYPE.mailTo,
311
+ url: 'hi@example.com',
312
+ }),
313
+ });
314
+
315
+ expect(wrapper.get(`${CTA} a`).attributes('href')).toBe(
316
+ 'mailto:hi@example.com',
317
+ );
318
+ });
319
+
320
+ test('renders a phone number as a tel link', async () => {
321
+ const wrapper = await mountHeader({
322
+ headerButton: aHeaderButton({
323
+ buttonLinkType: BUTTON_LINK_TYPE.tel,
324
+ url: '+61400000000',
325
+ }),
326
+ });
327
+
328
+ expect(wrapper.get(`${CTA} a`).attributes('href')).toBe(
329
+ 'tel:+61400000000',
330
+ );
331
+ });
332
+ });
333
+
334
+ describe('maker click guard', () => {
335
+ // In the maker preview the CTA is fully inert: it must not select the header
336
+ // panel (no bubble) and must not follow its link (default prevented).
337
+ test('a preview CTA click neither bubbles to the header container nor follows the link', async () => {
338
+ const wrapper = await mountHeader({
339
+ headerButton: aHeaderButton(),
340
+ isPreview: true,
341
+ });
342
+
343
+ const event = new MouseEvent('click', {
344
+ bubbles: true,
345
+ cancelable: true,
346
+ });
347
+
348
+ wrapper.get(`${CTA} a`).element.dispatchEvent(event);
349
+
350
+ expect(event.defaultPrevented).toBe(true);
351
+ expect(wrapper.emitted('header-container-click')).toBeUndefined();
352
+ });
353
+
354
+ test('a published CTA click follows the link (default not prevented)', async () => {
355
+ const wrapper = await mountHeader({ headerButton: aHeaderButton() });
356
+
357
+ const event = new MouseEvent('click', {
358
+ bubbles: true,
359
+ cancelable: true,
360
+ });
361
+
362
+ wrapper.get(`${CTA} a`).element.dispatchEvent(event);
363
+
364
+ expect(event.defaultPrevented).toBe(false);
365
+ });
366
+
367
+ test('still reports a click on the header container itself in preview', async () => {
368
+ const wrapper = await mountHeader({
369
+ headerButton: aHeaderButton(),
370
+ isPreview: true,
371
+ });
372
+
373
+ await wrapper.get('.c-page-header-container').trigger('click');
374
+
375
+ expect(wrapper.emitted('header-container-click')).toHaveLength(1);
376
+ });
377
+ });
378
+ });
@@ -0,0 +1,86 @@
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 header constructors copy known fields one-by-one, so an uncopied field is
21
+ // silently dropped on every deserialize.
22
+ describe('header button persistence', () => {
23
+ describe('PageHeader* constructor', () => {
24
+ test('keeps a persisted headerButton on both viewports', () => {
25
+ expect(
26
+ new PageHeaderDesktop({ headerButton: aHeaderButton() }).headerButton,
27
+ ).toEqual(aHeaderButton());
28
+ expect(
29
+ new PageHeaderMobile({ headerButton: aHeaderButton() }).headerButton,
30
+ ).toEqual(aHeaderButton());
31
+ });
32
+
33
+ test('leaves headerButton unset when absent (no default seeded)', () => {
34
+ expect(new PageHeaderDesktop().headerButton).toBeUndefined();
35
+ expect(new PageHeaderMobile().headerButton).toBeUndefined();
36
+ });
37
+
38
+ test('keeps a page link button with buttonPageId', () => {
39
+ const button: HeaderButton = {
40
+ isVisible: true,
41
+ text: 'About',
42
+ buttonLinkType: BUTTON_LINK_TYPE.page,
43
+ buttonPageId: 'page-2',
44
+ };
45
+
46
+ expect(
47
+ new PageHeaderDesktop({ headerButton: button }).headerButton,
48
+ ).toEqual(button);
49
+ });
50
+ });
51
+
52
+ describe('BrandPageModel round-trip', () => {
53
+ test('survives deserialization from persisted JSON on both viewports', () => {
54
+ const model = new BrandPageModel({
55
+ styles: {},
56
+ pages: [],
57
+ pageHeaderDesktop: { headerButton: aHeaderButton() },
58
+ pageHeaderMobile: { headerButton: aHeaderButton() },
59
+ });
60
+
61
+ expect(model.pageHeaderDesktop.headerButton).toEqual(aHeaderButton());
62
+ expect(model.pageHeaderMobile.headerButton).toEqual(aHeaderButton());
63
+ });
64
+
65
+ test('survives a second pass through the model (re-serialize then re-hydrate)', () => {
66
+ const first = new BrandPageModel({
67
+ styles: {},
68
+ pages: [],
69
+ pageHeaderDesktop: { headerButton: aHeaderButton() },
70
+ pageHeaderMobile: { headerButton: aHeaderButton() },
71
+ });
72
+
73
+ const second = new BrandPageModel(JSON.parse(JSON.stringify(first)));
74
+
75
+ expect(second.pageHeaderDesktop.headerButton).toEqual(aHeaderButton());
76
+ expect(second.pageHeaderMobile.headerButton).toEqual(aHeaderButton());
77
+ });
78
+
79
+ test('an existing site with no headerButton stays without one', () => {
80
+ const model = new BrandPageModel({ styles: {}, pages: [] });
81
+
82
+ expect(model.pageHeaderDesktop.headerButton).toBeUndefined();
83
+ expect(model.pageHeaderMobile.headerButton).toBeUndefined();
84
+ });
85
+ });
86
+ });
@@ -713,6 +713,17 @@ export class NavModel {
713
713
  }
714
714
  }
715
715
 
716
+ export interface HeaderButton {
717
+ isVisible: boolean;
718
+ text: string;
719
+ buttonLinkType: ButtonLinkType;
720
+ url?: string;
721
+ buttonPageId?: string;
722
+ buttonTextStyle?: TextStyleDeclaration;
723
+ fillColor?: string;
724
+ borderColor?: string;
725
+ }
726
+
716
727
  type AbstractPageHeaderStylesType = Partial<AbstractPageHeaderStyles>;
717
728
 
718
729
  export abstract class AbstractPageHeaderStyles {
@@ -742,6 +753,9 @@ export abstract class AbstractPageHeaderStyles {
742
753
  style: BrandPageCssStyle;
743
754
  viewport: ViewportsType;
744
755
 
756
+ /** Absent means no header call-to-action button — never seed a default. */
757
+ headerButton?: HeaderButton;
758
+
745
759
  protected constructor(options: AbstractPageHeaderStylesType = {}) {
746
760
  if (typeof options.viewport === 'undefined') {
747
761
  throw new Error('"viewport" is required');
@@ -779,6 +793,8 @@ export abstract class AbstractPageHeaderStyles {
779
793
  ...(options.backgroundStyle || {}),
780
794
  backgroundOpacity: options.backgroundStyle?.backgroundOpacity ?? 100,
781
795
  };
796
+
797
+ this.headerButton = options.headerButton;
782
798
  }
783
799
  }
784
800
 
@@ -156,6 +156,7 @@ exports[`migration.util > Banner Contents - text Align and Font Family should no
156
156
  "backgroundOpacity": 100,
157
157
  },
158
158
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
159
+ "headerButton": undefined,
159
160
  "isLogoOptional": false,
160
161
  "isLogoVisible": true,
161
162
  "logoImage": {
@@ -231,6 +232,7 @@ exports[`migration.util > Banner Contents - text Align and Font Family should no
231
232
  "backgroundOpacity": 100,
232
233
  },
233
234
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
235
+ "headerButton": undefined,
234
236
  "isLogoOptional": false,
235
237
  "isLogoVisible": true,
236
238
  "logoImage": {
@@ -653,6 +655,7 @@ exports[`migration.util > migrateBrandPageModel should return brand page model w
653
655
  "backgroundOpacity": 100,
654
656
  },
655
657
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
658
+ "headerButton": undefined,
656
659
  "isLogoOptional": false,
657
660
  "isLogoVisible": true,
658
661
  "logoImage": {
@@ -728,6 +731,7 @@ exports[`migration.util > migrateBrandPageModel should return brand page model w
728
731
  "backgroundOpacity": 100,
729
732
  },
730
733
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
734
+ "headerButton": undefined,
731
735
  "isLogoOptional": false,
732
736
  "isLogoVisible": true,
733
737
  "logoImage": {
@@ -1134,6 +1138,7 @@ exports[`migration.util > migrateBrandPageModel should return brand page model w
1134
1138
  "backgroundOpacity": 100,
1135
1139
  },
1136
1140
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
1141
+ "headerButton": undefined,
1137
1142
  "isLogoOptional": false,
1138
1143
  "isLogoVisible": true,
1139
1144
  "logoImage": {
@@ -1209,6 +1214,7 @@ exports[`migration.util > migrateBrandPageModel should return brand page model w
1209
1214
  "backgroundOpacity": 100,
1210
1215
  },
1211
1216
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
1217
+ "headerButton": undefined,
1212
1218
  "isLogoOptional": false,
1213
1219
  "isLogoVisible": true,
1214
1220
  "logoImage": {
@@ -141,6 +141,7 @@ exports[`migration02.ts > migration02 should return converted letterspacing 1`]
141
141
  "backgroundOpacity": 100,
142
142
  },
143
143
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
144
+ "headerButton": undefined,
144
145
  "isLogoOptional": false,
145
146
  "isLogoVisible": true,
146
147
  "logoDraftToken": "",
@@ -217,6 +218,7 @@ exports[`migration02.ts > migration02 should return converted letterspacing 1`]
217
218
  "backgroundOpacity": 100,
218
219
  },
219
220
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
221
+ "headerButton": undefined,
220
222
  "isLogoOptional": false,
221
223
  "isLogoVisible": true,
222
224
  "logoDraftToken": "",
@@ -140,6 +140,7 @@ BrandPageModel {
140
140
  "backgroundOpacity": 100,
141
141
  },
142
142
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
143
+ "headerButton": undefined,
143
144
  "isLogoOptional": false,
144
145
  "isLogoVisible": true,
145
146
  "logoDraftToken": "",
@@ -216,6 +217,7 @@ BrandPageModel {
216
217
  "backgroundOpacity": 100,
217
218
  },
218
219
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
220
+ "headerButton": undefined,
219
221
  "isLogoOptional": false,
220
222
  "isLogoVisible": true,
221
223
  "logoDraftToken": "",
@@ -601,6 +603,7 @@ BrandPageModel {
601
603
  "backgroundOpacity": 100,
602
604
  },
603
605
  "displayStyle": "desktop-tab-vertical-stack-centered-logo-centered-menu",
606
+ "headerButton": undefined,
604
607
  "isLogoOptional": false,
605
608
  "isLogoVisible": true,
606
609
  "logoDraftToken": "",
@@ -677,6 +680,7 @@ BrandPageModel {
677
680
  "backgroundOpacity": 100,
678
681
  },
679
682
  "displayStyle": "mobile-tab-vertical-stack-centered-logo-centered-menu",
683
+ "headerButton": undefined,
680
684
  "isLogoOptional": false,
681
685
  "isLogoVisible": true,
682
686
  "logoDraftToken": "",
@@ -45,6 +45,14 @@ export const getBoxShadowValue = (buttonStyles: ButtonStyles): string => {
45
45
  return boxShadow;
46
46
  };
47
47
 
48
+ export const BUTTON_VARIANTS = {
49
+ section: 'section',
50
+ header: 'header',
51
+ } as const;
52
+
53
+ export type ButtonVariant =
54
+ (typeof BUTTON_VARIANTS)[keyof typeof BUTTON_VARIANTS];
55
+
48
56
  // The "decorative" button styles — brushstroke / arrow / torn-paper — whose visual
49
57
  // is drawn by ButtonStyleWrapper rather than plain CSS. Their banner-like SVG
50
58
  // decoration needs the button to be reasonably wide to render legibly, so narrow