@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,237 @@
1
+ <template>
2
+ <div data-component="header-button-settings">
3
+ <p class="header-button-settings_option-title tw-mb-2">
4
+ {{ tr('pageSettingsSection.buttonLabel') }}
5
+ </p>
6
+ <TextField
7
+ id="headerButton"
8
+ data-test-header-button-text
9
+ name="text"
10
+ :value="text"
11
+ :placeholder="tr('pageSettingsSection.buttonLabelPlaceholder')"
12
+ :max-length="BUTTON_TEXT_MAX_LENGTH"
13
+ :settings="validFieldSettings"
14
+ @update:model-value="onTextChange"
15
+ />
16
+
17
+ <p class="header-button-settings_option-title tw-mt-4 tw-mb-2">
18
+ {{ tr('pageSettingsSection.buttonLink') }}
19
+ </p>
20
+ <ButtonGroup
21
+ data-test-header-button-link-type
22
+ :options="linkTypeOptions"
23
+ :columns="linkTypeColumns"
24
+ @on-select="onSelectLinkType"
25
+ />
26
+
27
+ <div class="tw-mt-2">
28
+ <PageDropdownField
29
+ v-if="buttonLinkType === BUTTON_LINK_TYPE.page"
30
+ id="headerButton"
31
+ data-test-header-button-page
32
+ name="buttonPageId"
33
+ :value="buttonPageId"
34
+ :placeholder="contentSettingsTr('selectAPagePlaceholder')"
35
+ @update:model-value="onPageChange"
36
+ />
37
+ <EmailField
38
+ v-else-if="buttonLinkType === BUTTON_LINK_TYPE.mailTo"
39
+ id="headerButton"
40
+ data-test-header-button-email
41
+ name="url"
42
+ role="email"
43
+ :value="url"
44
+ :placeholder="contentSettingsTr('emailPlaceholder')"
45
+ :settings="validFieldSettings"
46
+ @update:model-value="onUrlChange"
47
+ />
48
+ <TelField
49
+ v-else-if="buttonLinkType === BUTTON_LINK_TYPE.tel"
50
+ id="headerButton"
51
+ data-test-header-button-tel
52
+ name="url"
53
+ role="tel"
54
+ :value="url"
55
+ :placeholder="tr('pageSettingsSection.buttonPhonePlaceholder')"
56
+ :settings="validFieldSettings"
57
+ @update:model-value="onUrlChange"
58
+ />
59
+ <UrlField
60
+ v-else
61
+ id="headerButton"
62
+ data-test-header-button-url
63
+ name="url"
64
+ role="url"
65
+ :value="url"
66
+ :placeholder="contentSettingsTr('exampleWebsitePlaceholder')"
67
+ :settings="validFieldSettings"
68
+ @update:model-value="onUrlChange"
69
+ />
70
+ </div>
71
+
72
+ <TextStyleField
73
+ id="header-button-text-style"
74
+ data-test-header-button-text-style
75
+ name="buttonTextStyle"
76
+ :label="contentSettingsTr('buttonTextStyleLabel')"
77
+ :is-admin-maker="isAdminMaker"
78
+ :logo-theme="logoTheme"
79
+ :value="buttonTextStyle"
80
+ :show-text-alignment-options="false"
81
+ @update:model-value="onTextStyleChange"
82
+ />
83
+
84
+ <ColorField
85
+ id="fillColor"
86
+ data-test-header-button-fill-color
87
+ name="fillColor"
88
+ :label="tr('pageSettingsSection.buttonFillColor')"
89
+ :value="fillColor"
90
+ :is-admin-maker="isAdminMaker"
91
+ :logo-theme="logoTheme"
92
+ @update:model-value="onFillColorChange"
93
+ />
94
+
95
+ <ColorField
96
+ v-if="supportsBorderColor"
97
+ id="borderColor"
98
+ data-test-header-button-border-color
99
+ name="borderColor"
100
+ :label="tr('pageSettingsSection.buttonBorderColor')"
101
+ :value="borderColor"
102
+ :is-admin-maker="isAdminMaker"
103
+ :logo-theme="logoTheme"
104
+ @update:model-value="onBorderColorChange"
105
+ />
106
+ </div>
107
+ </template>
108
+ <script setup lang="ts">
109
+ import { computed } from 'vue';
110
+ import { ButtonGroup } from '@designcrowd/fe-shared-lib';
111
+ import type {
112
+ BrandPageContext,
113
+ HeaderButton,
114
+ LogoTheme,
115
+ TextStyleDeclaration,
116
+ } from '@brand-page';
117
+ import { BUTTON_LINK_TYPE } from '@brand-page';
118
+ import type { ButtonLinkType } from '@brand-page';
119
+ import type { ButtonGroupOption } from '@brand-page-maker/models/button-group.model';
120
+ import { buttonLinkStyleConvertor } from '@brand-page-maker/convertors';
121
+ import { useUiPageSettingsStore } from '../../store';
122
+ import { useUiStyleSettingsStore } from '../../../style-settings/store/style-settings-store';
123
+ import type { ContentItemFieldSettings } from '../../../content-settings/models';
124
+ import ColorField from '../../../content-settings/components/color-field/ColorField.vue';
125
+ import EmailField from '../../../content-settings/components/email-field/EmailField.vue';
126
+ import PageDropdownField from '../../../content-settings/components/page-dropdown-field/PageDropdownField.vue';
127
+ import TelField from '../../../content-settings/components/tel-field/TelField.vue';
128
+ import TextField from '../../../content-settings/components/text-field/TextField.vue';
129
+ import TextStyleField from '../../../content-settings/components/text-style-field/TextStyleField.vue';
130
+ import UrlField from '../../../content-settings/components/url-field/UrlField.vue';
131
+ import { contentSettingsTr, tr } from '@src/composables/useTranslate';
132
+
133
+ /** Matches the section buttons' label cap (`buttonText` in the field configs). */
134
+ const BUTTON_TEXT_MAX_LENGTH = 60;
135
+
136
+ interface Props {
137
+ value?: HeaderButton;
138
+ isAdminMaker: boolean;
139
+ logoTheme: LogoTheme;
140
+ }
141
+
142
+ const props = defineProps<Props>();
143
+
144
+ const pageSettingsStore = useUiPageSettingsStore();
145
+ const styleSettingsStore = useUiStyleSettingsStore();
146
+
147
+ // The site's selected button style decides whether a border exists at all — the
148
+ // default and several others draw none, so a border colour would be inert. Same
149
+ // capability gate the global Style panel applies to this control.
150
+ const supportsBorderColor = computed(
151
+ (): boolean =>
152
+ styleSettingsStore.selectedButtonStyleConfig?.options.includes(
153
+ 'buttonBorderColor',
154
+ ) === true,
155
+ );
156
+
157
+ const emit = defineEmits<{
158
+ (e: 'on-change', patch: Partial<HeaderButton>): void;
159
+ }>();
160
+
161
+ const text = computed((): string => props.value?.text || '');
162
+ const url = computed((): string => props.value?.url || '');
163
+ const buttonPageId = computed((): string => props.value?.buttonPageId || '');
164
+ const fillColor = computed((): string => props.value?.fillColor || '');
165
+ const borderColor = computed((): string => props.value?.borderColor || '');
166
+ const buttonTextStyle = computed(
167
+ (): TextStyleDeclaration => props.value?.buttonTextStyle || {},
168
+ );
169
+ const buttonLinkType = computed(
170
+ (): ButtonLinkType => props.value?.buttonLinkType || BUTTON_LINK_TYPE.url,
171
+ );
172
+
173
+ /**
174
+ * The url/email/tel inputs surface `settings.errorMessage` when `isValid` is
175
+ * false, and `TelField` renders its error row whenever `isValid` is not `true`.
176
+ * There is no validator behind these fields yet, so state them valid.
177
+ */
178
+ const validFieldSettings: ContentItemFieldSettings = {
179
+ errorMessage: '',
180
+ isValid: true,
181
+ };
182
+
183
+ const linkTypeConfig = computed(() =>
184
+ buttonLinkStyleConvertor.convert(
185
+ buttonLinkType.value,
186
+ // The convertor reads `pages` only, to drop "Page" on a single-page site;
187
+ // a settings panel has no full render context to hand it. Read off the
188
+ // store rather than passed in, so adding a page re-offers the option.
189
+ { pages: pageSettingsStore.pages } as unknown as BrandPageContext,
190
+ ),
191
+ );
192
+
193
+ const linkTypeOptions = computed(
194
+ (): ButtonGroupOption[] => linkTypeConfig.value.options,
195
+ );
196
+
197
+ const linkTypeColumns = computed(
198
+ (): number | undefined => linkTypeConfig.value.columns,
199
+ );
200
+
201
+ const onTextChange = ({ value }: { value: string }): void => {
202
+ emit('on-change', { text: value });
203
+ };
204
+
205
+ const onSelectLinkType = (option: ButtonGroupOption): void => {
206
+ emit('on-change', { buttonLinkType: option.value as ButtonLinkType });
207
+ };
208
+
209
+ const onUrlChange = ({ value }: { value: string }): void => {
210
+ emit('on-change', { url: value });
211
+ };
212
+
213
+ const onPageChange = ({ value }: { value: string }): void => {
214
+ emit('on-change', { buttonPageId: value });
215
+ };
216
+
217
+ const onTextStyleChange = ({
218
+ value,
219
+ }: {
220
+ value: TextStyleDeclaration;
221
+ }): void => {
222
+ emit('on-change', { buttonTextStyle: value });
223
+ };
224
+
225
+ const onFillColorChange = ({ value }: { value: string }): void => {
226
+ emit('on-change', { fillColor: value });
227
+ };
228
+
229
+ const onBorderColorChange = ({ value }: { value: string }): void => {
230
+ emit('on-change', { borderColor: value });
231
+ };
232
+ </script>
233
+ <style scoped>
234
+ .header-button-settings_option-title {
235
+ @apply tw-font-sans tw-font-bold tw-text-secondary-800 tw-text-sm;
236
+ }
237
+ </style>
@@ -0,0 +1,160 @@
1
+ import { describe, test, expect, beforeEach } from 'vitest';
2
+ import { createPinia, setActivePinia } from 'pinia';
3
+ import { shallowMount } from '@vue/test-utils';
4
+ import { ButtonGroup } from '@designcrowd/fe-shared-lib';
5
+ import {
6
+ HEADER_DISPLAY,
7
+ HEADER_DISPLAY_STYLE_CONFIGS,
8
+ NAVIGATION_DISPLAY,
9
+ VIEWPORTS,
10
+ } from '@brand-page';
11
+ import type { ViewportsType } from '@brand-page';
12
+ import type { ButtonGroupOption } from '@brand-page-maker/models/button-group.model';
13
+ import HeaderDisplayStyle from './HeaderDisplayStyle.vue';
14
+
15
+ // BP-5852: enabling the header button swaps the six DESKTOP BAR tiles to the
16
+ // with-button artwork. No such artwork exists for the mobile bar, hamburger or
17
+ // no-logo sets, so those keep their plain icons; `dataAttribute` never changes
18
+ // (selection state and the bc-net e2e locators key off it).
19
+ describe('HeaderDisplayStyle — header-button icon swap', () => {
20
+ beforeEach(() => {
21
+ setActivePinia(createPinia());
22
+ });
23
+
24
+ const mountTiles = ({
25
+ displayStyle,
26
+ navigationDisplayType,
27
+ viewport = VIEWPORTS.desktop as ViewportsType,
28
+ isLogoVisible = true,
29
+ showHeaderButton = false,
30
+ }: {
31
+ displayStyle: string;
32
+ navigationDisplayType: string;
33
+ viewport?: ViewportsType;
34
+ isLogoVisible?: boolean;
35
+ showHeaderButton?: boolean;
36
+ }): ButtonGroupOption[] => {
37
+ const wrapper = shallowMount(HeaderDisplayStyle, {
38
+ props: {
39
+ displayStyle,
40
+ navigationDisplayType,
41
+ viewport,
42
+ isLogoVisible,
43
+ showHeaderButton,
44
+ onSelect: () => undefined,
45
+ },
46
+ });
47
+
48
+ return wrapper
49
+ .findComponent(ButtonGroup)
50
+ .props('options') as ButtonGroupOption[];
51
+ };
52
+
53
+ const desktopBar = (showHeaderButton: boolean) =>
54
+ mountTiles({
55
+ displayStyle: HEADER_DISPLAY.DESKTOP_TAB_1,
56
+ navigationDisplayType: NAVIGATION_DISPLAY.TAB_1,
57
+ showHeaderButton,
58
+ });
59
+
60
+ const iconNames = (options: ButtonGroupOption[]) =>
61
+ options.map((option) => ('iconName' in option ? option.iconName : ''));
62
+
63
+ test('off: the six desktop bar tiles keep their plain icons', () => {
64
+ expect(iconNames(desktopBar(false))).toEqual([
65
+ 'header-desktop-nav-style-tab-display-style-1',
66
+ 'header-desktop-nav-style-tab-display-style-2',
67
+ 'header-desktop-nav-style-tab-display-style-3',
68
+ 'header-desktop-nav-style-tab-display-style-4',
69
+ 'header-desktop-nav-style-tab-display-style-5',
70
+ 'header-desktop-nav-style-tab-display-style-6',
71
+ ]);
72
+ });
73
+
74
+ test('on: the six desktop bar tiles swap to the with-button icons', () => {
75
+ expect(iconNames(desktopBar(true))).toEqual([
76
+ 'header-desktop-nav-style-tab-display-style-with-button-1',
77
+ 'header-desktop-nav-style-tab-display-style-with-button-2',
78
+ 'header-desktop-nav-style-tab-display-style-with-button-3',
79
+ 'header-desktop-nav-style-tab-display-style-with-button-4',
80
+ 'header-desktop-nav-style-tab-display-style-with-button-5',
81
+ 'header-desktop-nav-style-tab-display-style-with-button-6',
82
+ ]);
83
+ });
84
+
85
+ test('value, dataAttribute and iconViewBox survive the swap', () => {
86
+ const off = desktopBar(false);
87
+ const on = desktopBar(true);
88
+
89
+ expect(on.map((option) => option.value)).toEqual(
90
+ off.map((option) => option.value),
91
+ );
92
+ expect(on.map((option) => option.dataAttribute)).toEqual(
93
+ off.map((option) => option.dataAttribute),
94
+ );
95
+ expect(
96
+ on.map((option) => ('iconViewBox' in option ? option.iconViewBox : '')),
97
+ ).toEqual([
98
+ '0 0 160 56',
99
+ '0 0 160 56',
100
+ '0 0 160 56',
101
+ '0 0 160 56',
102
+ '0 0 160 56',
103
+ '0 0 160 56',
104
+ ]);
105
+ });
106
+
107
+ test('the mobile bar tiles are left alone', () => {
108
+ const options = mountTiles({
109
+ displayStyle: HEADER_DISPLAY.MOBILE_TAB_1,
110
+ navigationDisplayType: NAVIGATION_DISPLAY.TAB_1,
111
+ viewport: VIEWPORTS.mobile,
112
+ showHeaderButton: true,
113
+ });
114
+
115
+ expect(
116
+ iconNames(options).every((name) => !name.includes('with-button')),
117
+ ).toBe(true);
118
+ });
119
+
120
+ test('the desktop hamburger tiles are left alone', () => {
121
+ const options = mountTiles({
122
+ displayStyle: HEADER_DISPLAY.DESKTOP_HAMBURGER_1,
123
+ navigationDisplayType: NAVIGATION_DISPLAY.HAMBURGER_1,
124
+ showHeaderButton: true,
125
+ });
126
+
127
+ expect(
128
+ iconNames(options).every((name) => !name.includes('with-button')),
129
+ ).toBe(true);
130
+ });
131
+
132
+ test('the desktop no-logo bar tiles are left alone', () => {
133
+ const options = mountTiles({
134
+ displayStyle: HEADER_DISPLAY.DESKTOP_TAB_NO_LOGO_1,
135
+ navigationDisplayType: NAVIGATION_DISPLAY.TAB_1,
136
+ isLogoVisible: false,
137
+ showHeaderButton: true,
138
+ });
139
+
140
+ expect(
141
+ iconNames(options).every((name) => !name.includes('with-button')),
142
+ ).toBe(true);
143
+ });
144
+
145
+ test('the shared display-style configs are never mutated in place', () => {
146
+ desktopBar(true);
147
+
148
+ // Absolute assertions, not a before/after diff: the configs are authored with
149
+ // isSelected: false and plain icon names, so any in-place write (selection or
150
+ // the with-button swap) leaves a value these checks catch — a snapshot taken
151
+ // after a mutation would not.
152
+ HEADER_DISPLAY_STYLE_CONFIGS.forEach((config) => {
153
+ expect(config.option.isSelected).toBe(false);
154
+
155
+ if ('iconName' in config.option) {
156
+ expect(config.option.iconName).not.toContain('with-button');
157
+ }
158
+ });
159
+ });
160
+ });
@@ -30,14 +30,41 @@ import { useBrandPageMakerStore } from '@brand-page-maker/stores';
30
30
  import { useUiPageSettingsStore } from '../../store';
31
31
  import { tr } from '@src/composables/useTranslate';
32
32
 
33
+ /**
34
+ * Only the six desktop BAR tiles have with-button artwork; every other icon
35
+ * name (mobile bar, hamburger, no-logo) keeps its plain tile.
36
+ */
37
+ const DESKTOP_BAR_ICON_PREFIX = 'header-desktop-nav-style-tab-display-style-';
38
+
33
39
  interface Props {
34
40
  displayStyle: string;
35
41
  onSelect: (displayStyle: string) => void;
36
42
  navigationDisplayType: string;
37
43
  isLogoVisible: boolean;
38
44
  viewport: ViewportsType;
45
+ showHeaderButton?: boolean;
39
46
  }
40
- const props = defineProps<Props>();
47
+ const props = withDefaults(defineProps<Props>(), {
48
+ showHeaderButton: false,
49
+ });
50
+
51
+ const withHeaderButtonIcon = (option: ButtonGroupOption): ButtonGroupOption => {
52
+ if (
53
+ !props.showHeaderButton ||
54
+ !('iconName' in option) ||
55
+ !option.iconName.startsWith(DESKTOP_BAR_ICON_PREFIX)
56
+ ) {
57
+ return option;
58
+ }
59
+
60
+ return {
61
+ ...option,
62
+ iconName: option.iconName.replace(
63
+ DESKTOP_BAR_ICON_PREFIX,
64
+ `${DESKTOP_BAR_ICON_PREFIX}with-button-`,
65
+ ),
66
+ };
67
+ };
41
68
 
42
69
  const brandPageMakerStore = useBrandPageMakerStore();
43
70
  const pageSettingsStore = useUiPageSettingsStore();
@@ -72,13 +99,15 @@ const options = computed(() => {
72
99
  }
73
100
  }
74
101
 
75
- const filteredOptions = filteredConfigs.reduce((arr, config, _index) => {
76
- config.option.isSelected = props.displayStyle === config.option.value;
77
-
78
- arr.push(config.option);
79
-
80
- return arr;
81
- }, [] as ButtonGroupOption[]);
102
+ // Copied, never mutated in place: `config.option` belongs to the shared
103
+ // HEADER_DISPLAY_STYLE_CONFIGS module constant, so writing `isSelected` onto
104
+ // it leaked this panel's selection into every other consumer.
105
+ const filteredOptions: ButtonGroupOption[] = filteredConfigs.map((config) =>
106
+ withHeaderButtonIcon({
107
+ ...config.option,
108
+ isSelected: props.displayStyle === config.option.value,
109
+ }),
110
+ );
82
111
 
83
112
  const selectedOption = filteredOptions.find((x) => x.isSelected);
84
113
 
@@ -0,0 +1,113 @@
1
+ import { describe, test, expect, beforeEach } from 'vitest';
2
+ import { createPinia, setActivePinia } from 'pinia';
3
+ import { VIEWPORTS } from '@brand-page';
4
+ import type { HeaderButton } from '@brand-page';
5
+ import mixin from './PageSettingsSectionStyles.mixin';
6
+ import { useUiPageSettingsStore } from '@shared/page-settings/store';
7
+
8
+ // BP-5852 AC: the header button is configured once but must apply to BOTH
9
+ // viewports. There is no generic sync mechanism — the handler dual-writes, the
10
+ // way onCopyrightTextChange / onSocialLinksChange do. The method is pure over
11
+ // `this`, so it's invoked against a hand-built context wired to the real store
12
+ // (the PageSettings.mixin testing pattern) rather than mounting the panel.
13
+ const { onHeaderButtonChange } = (
14
+ mixin as unknown as {
15
+ methods: {
16
+ onHeaderButtonChange(this: unknown, patch: Partial<HeaderButton>): void;
17
+ };
18
+ }
19
+ ).methods;
20
+
21
+ describe('PageSettingsSectionStyles.mixin onHeaderButtonChange', () => {
22
+ let pageSettingsStore: ReturnType<typeof useUiPageSettingsStore>;
23
+
24
+ beforeEach(() => {
25
+ setActivePinia(createPinia());
26
+ pageSettingsStore = useUiPageSettingsStore();
27
+ });
28
+
29
+ const headers = () => pageSettingsStore.sections.header;
30
+
31
+ // `headerButton` on the CURRENT viewport is what the panel reads and the
32
+ // handler patches — desktop here, as the panel opens on desktop.
33
+ const context = () => ({
34
+ pageSettingsStore,
35
+ headerButton: headers()[VIEWPORTS.desktop].headerButton,
36
+ });
37
+
38
+ const change = (patch: Partial<HeaderButton>) =>
39
+ onHeaderButtonChange.call(context(), patch);
40
+
41
+ test('the first write seeds a full button on both viewports', () => {
42
+ change({ isVisible: true });
43
+
44
+ expect(headers()[VIEWPORTS.desktop].headerButton).toEqual({
45
+ isVisible: true,
46
+ text: '',
47
+ buttonLinkType: 'url',
48
+ });
49
+ expect(headers()[VIEWPORTS.mobile].headerButton).toEqual(
50
+ headers()[VIEWPORTS.desktop].headerButton,
51
+ );
52
+ });
53
+
54
+ test('off by default — nothing is seeded until something is written', () => {
55
+ expect(headers()[VIEWPORTS.desktop].headerButton).toBeUndefined();
56
+ expect(headers()[VIEWPORTS.mobile].headerButton).toBeUndefined();
57
+ });
58
+
59
+ test('later writes patch one key and still land on both viewports', () => {
60
+ change({ isVisible: true });
61
+ change({ text: 'Book now' });
62
+ change({ buttonLinkType: 'tel', url: '12345-6789' });
63
+
64
+ const expected: HeaderButton = {
65
+ isVisible: true,
66
+ text: 'Book now',
67
+ buttonLinkType: 'tel',
68
+ url: '12345-6789',
69
+ };
70
+
71
+ expect(headers()[VIEWPORTS.desktop].headerButton).toEqual(expected);
72
+ expect(headers()[VIEWPORTS.mobile].headerButton).toEqual(expected);
73
+ });
74
+
75
+ test('each viewport holds its own object, not a shared reference', () => {
76
+ change({ isVisible: true });
77
+
78
+ expect(headers()[VIEWPORTS.mobile].headerButton).not.toBe(
79
+ headers()[VIEWPORTS.desktop].headerButton,
80
+ );
81
+ });
82
+
83
+ test('a viewport that somehow diverged is reconciled by the next write', () => {
84
+ change({ isVisible: true, text: 'Contact' });
85
+
86
+ headers()[VIEWPORTS.mobile].headerButton = {
87
+ isVisible: false,
88
+ text: 'stale',
89
+ buttonLinkType: 'url',
90
+ };
91
+
92
+ change({ text: 'Contact us' });
93
+
94
+ expect(headers()[VIEWPORTS.mobile].headerButton).toEqual(
95
+ headers()[VIEWPORTS.desktop].headerButton,
96
+ );
97
+ expect(headers()[VIEWPORTS.mobile].headerButton?.text).toBe('Contact us');
98
+ });
99
+
100
+ test('toggling off keeps the config, only flipping isVisible', () => {
101
+ change({ isVisible: true, text: 'Contact us' });
102
+ change({ isVisible: false });
103
+
104
+ expect(headers()[VIEWPORTS.desktop].headerButton).toEqual({
105
+ isVisible: false,
106
+ text: 'Contact us',
107
+ buttonLinkType: 'url',
108
+ });
109
+ expect(headers()[VIEWPORTS.mobile].headerButton).toEqual(
110
+ headers()[VIEWPORTS.desktop].headerButton,
111
+ );
112
+ });
113
+ });
@@ -9,6 +9,7 @@ import type {
9
9
  PageSettingsFontSettingsOption,
10
10
  } from '../../models';
11
11
  import type {
12
+ HeaderButton,
12
13
  LogoTheme,
13
14
  NavigationDisplayType,
14
15
  NavigationType,
@@ -27,6 +28,7 @@ import type {
27
28
  BackgroundColorStyle,
28
29
  } from '@brand-page';
29
30
  import {
31
+ BUTTON_LINK_TYPE,
30
32
  LOGO_SLIDER_DESKTOP_MAX,
31
33
  LOGO_SLIDER_DESKTOP_MIN,
32
34
  LOGO_SLIDER_MOBILE_MAX,
@@ -62,6 +64,17 @@ import { FabricRenderClient } from '../../../../clients/fabric-render-client';
62
64
  import { parseLogoThemeToCssStyles } from '@brand-page/utils';
63
65
  import { commonTr } from '@src/composables/useTranslate';
64
66
 
67
+ /**
68
+ * Seed for the first write to `headerButton` (the model deliberately holds no
69
+ * default — absent means "no button"). Url is the link kind the Figma shows
70
+ * pre-selected.
71
+ */
72
+ const NEW_HEADER_BUTTON: HeaderButton = {
73
+ isVisible: false,
74
+ text: '',
75
+ buttonLinkType: BUTTON_LINK_TYPE.url,
76
+ };
77
+
65
78
  const viewportsWithTr = () => [
66
79
  {
67
80
  key: VIEWPORTS.desktop,
@@ -162,6 +175,12 @@ export default defineComponent({
162
175
  header(): PageHeaderDesktop | PageHeaderMobile {
163
176
  return this.sections[PAGE_SECTIONS.header][this.viewport];
164
177
  },
178
+ headerButton(): HeaderButton | undefined {
179
+ return this.header.headerButton;
180
+ },
181
+ isHeaderButtonVisible(): boolean {
182
+ return this.headerButton?.isVisible === true;
183
+ },
165
184
  navigationType(): NavigationType {
166
185
  return getNavigationType(this.navigation.displayType);
167
186
  },
@@ -451,6 +470,34 @@ export default defineComponent({
451
470
  });
452
471
  },
453
472
 
473
+ onHeaderButtonChange(patch: Partial<HeaderButton>) {
474
+ const value: HeaderButton = {
475
+ ...NEW_HEADER_BUTTON,
476
+ ...this.headerButton,
477
+ ...patch,
478
+ };
479
+
480
+ // Desktop and mobile must hold the SAME button config (BP-5852 AC). The
481
+ // whole object is written to both from one source rather than merged
482
+ // per-viewport, so a pair that somehow diverged is reconciled instead of
483
+ // each side keeping its own leftovers.
484
+ //
485
+ // Each viewport gets its own copy: one shared object would alias the two
486
+ // branches of store state to a single instance.
487
+ this.pageSettingsStore.setPageSettingsSectionValue({
488
+ sectionName: PAGE_SECTIONS.header,
489
+ propName: 'headerButton',
490
+ value: { ...value },
491
+ viewport: VIEWPORTS.mobile,
492
+ });
493
+ this.pageSettingsStore.setPageSettingsSectionValue({
494
+ sectionName: PAGE_SECTIONS.header,
495
+ propName: 'headerButton',
496
+ value: { ...value },
497
+ viewport: VIEWPORTS.desktop,
498
+ });
499
+ },
500
+
454
501
  onItemListIntentImageReplace(
455
502
  name: PageSectionStyleOption,
456
503
  { index }: { index: number },
@@ -8,6 +8,12 @@
8
8
  "iconStyle": "Icon style",
9
9
  "logoSize": "Logo Size",
10
10
  "socialLinks": "Social Links",
11
- "copyright": "Copyright"
11
+ "copyright": "Copyright",
12
+ "buttonLabel": "Label",
13
+ "buttonLabelPlaceholder": "Contact Us",
14
+ "buttonLink": "Link",
15
+ "buttonPhonePlaceholder": "12345-6789",
16
+ "buttonFillColor": "Fill color",
17
+ "buttonBorderColor": "Border color"
12
18
  }
13
19
  }
@@ -12,6 +12,7 @@ import {
12
12
  import type { NavigationType } from '@brand-page';
13
13
  import type {
14
14
  Content,
15
+ HeaderButton,
15
16
  ImageGalleryItem,
16
17
  LinksItem,
17
18
  LogoImageModel,
@@ -242,6 +243,7 @@ export const useUiPageSettingsStore = defineStore('uiPageSettings', {
242
243
  | LinksItem[]
243
244
  | BrandPageCssStyle
244
245
  | Partial<ImageGalleryItem>
246
+ | HeaderButton
245
247
  | number
246
248
  | null
247
249
  | undefined;