@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,99 @@
1
+ import { v4 as uuid } from 'uuid';
2
+ import type { BrandPageModel, Content } from '@brand-page/models';
3
+ import type { ProductModel } from '@brand-page/models/shop.model';
4
+ import type { PaymentConfig } from '@brand-page/models/payment.model';
5
+ import { isPayment, isShopContent, isSupportMe } from '@brand-page/utils';
6
+ import { generateSku } from './skuUtils';
7
+
8
+ /**
9
+ * Refresh a (migrated, current-version) BrandPageModel so it is safe to persist as a *duplicate*.
10
+ * Two things travel with a verbatim copy that must not: client-minted identifiers that the
11
+ * brand-page-api keys a global row on, and the source owner's embedded payment connection. This
12
+ * rotates the former to fresh values and removes the latter, in place — the new owner reconnects
13
+ * a payment provider explicitly. Runs after migration, so it only ever sees the current shape.
14
+ *
15
+ * See docs/architecture/capabilities/shop-duplication.md.
16
+ */
17
+ export function refreshDuplicatedMetadata(
18
+ model: BrandPageModel,
19
+ ): BrandPageModel {
20
+ // Site-level payment connection: the authoritative one at checkout — `initialiseShopStore` prefers
21
+ // it over any content-level config — so it leaks the source owner's account even after the
22
+ // content-level connections below are cleared. Disconnect it too; the new owner reconnects.
23
+ model.paymentConfig = undefined;
24
+
25
+ model.pages.forEach((page) =>
26
+ page.contents.forEach((content) => {
27
+ if (isShopContent(content)) {
28
+ // Shop products collide on the global product / sku_key tables. The shop's payment-config
29
+ // link is written with throwaway ids (not the content id), so only the product ids rotate.
30
+ content.products.forEach(rotateProductIds);
31
+ disconnectPayment(content.shopPaymentConfig);
32
+ } else if (isPayment(content)) {
33
+ resetPaymentBlock(content, content.requestPaymentConfig);
34
+ } else if (isSupportMe(content)) {
35
+ resetPaymentBlock(content, content.supportMeConfig);
36
+ }
37
+ }),
38
+ );
39
+
40
+ return model;
41
+ }
42
+
43
+ function rotateProductIds(product: ProductModel): void {
44
+ product.id = uuid();
45
+
46
+ // The option id is the only value cross-referenced inside a product — SKUs point at product
47
+ // options by id and each skuKey embeds it — so it is remapped through one old→new map and
48
+ // reused everywhere it reappears. Every other id is standalone and just gets a fresh uuid.
49
+ const optionIdMap = new Map<string, string>();
50
+
51
+ product.variant.forEach((variant) => {
52
+ variant.id = uuid();
53
+ variant.options.forEach((option) => {
54
+ const newId = uuid();
55
+
56
+ optionIdMap.set(option.id, newId);
57
+ option.id = newId;
58
+ });
59
+ });
60
+
61
+ product.skus.forEach((sku) => {
62
+ sku.id = uuid();
63
+
64
+ sku.variant.forEach((skuVariant) => {
65
+ skuVariant.id = uuid();
66
+ skuVariant.option.id = optionIdMap.get(skuVariant.option.id) ?? uuid();
67
+ });
68
+
69
+ // Regenerate from the now-remapped option so the key embeds the new option id and a fresh
70
+ // random suffix — this is what keeps sku_key globally unique on the copy's first upsert.
71
+ sku.skuKey = generateSku(product.name, sku.variant[0]?.option);
72
+ });
73
+ }
74
+
75
+ /**
76
+ * request-payment / support-me content. The api keys `content_block_payment_config` on the content
77
+ * id (PK) and `brand_page_product` on the payment-option id (PK), both global. A verbatim copy
78
+ * would, on its next payment upsert, repoint the source's config link and adopt its product rows —
79
+ * so rotate both to make the copy independent, then drop the connection.
80
+ */
81
+ function resetPaymentBlock(content: Content, config: PaymentConfig): void {
82
+ content.id = uuid();
83
+
84
+ config.paymentOptions.forEach((option) => {
85
+ option.id = uuid();
86
+ });
87
+
88
+ disconnectPayment(config);
89
+ }
90
+
91
+ /**
92
+ * Drop the embedded payment connection (the source owner's payment/Stripe account). The section
93
+ * keeps its options and amounts but reverts to "not connected"; the new owner reconnects.
94
+ */
95
+ function disconnectPayment(config: PaymentConfig | undefined): void {
96
+ if (config) {
97
+ config.paymentConfig = undefined;
98
+ }
99
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, test, expect, beforeEach } from 'vitest';
2
+ import { createPinia, setActivePinia } from 'pinia';
3
+ import { shallowMount } from '@vue/test-utils';
4
+ import type { VueWrapper } from '@vue/test-utils';
5
+ import { Toggle } from '@designcrowd/fe-shared-lib';
6
+ import type { LogoTheme } from '@brand-page';
7
+ import { VIEWPORTS } from '@brand-page';
8
+ import HeaderSettingsPanel from './HeaderSettingsPanel.vue';
9
+ import HeaderButtonSettings from '../page-settings-section-styles/HeaderButtonSettings.vue';
10
+ import { useUiPageSettingsStore } from '../../store';
11
+ import { FabricRenderClient } from '../../../../clients/fabric-render-client';
12
+
13
+ // BP-5852: the header call-to-action button is brandsite-only, its toggle lives
14
+ // in the Button section's HEADER (final design), and every write syncs both
15
+ // viewports.
16
+ describe('HeaderSettingsPanel — Button section', () => {
17
+ beforeEach(() => {
18
+ setActivePinia(createPinia());
19
+ // The Logo section renders alongside the Button one and resolves logo URLs
20
+ // through the client singleton.
21
+ FabricRenderClient.setInstance({ baseUrl: 'http://localhost:3000' });
22
+ });
23
+
24
+ const CollapsiblePanelStub = {
25
+ template: '<div><slot name="header-actions" /><slot /></div>',
26
+ };
27
+
28
+ const mountPanel = (isBrandSite: boolean) =>
29
+ shallowMount(HeaderSettingsPanel, {
30
+ props: {
31
+ isAdminMaker: false,
32
+ isBrandSite,
33
+ logoTheme: {} as LogoTheme,
34
+ sectionName: 'header',
35
+ styleOptions: [],
36
+ },
37
+ global: {
38
+ // The sections live inside CollapsiblePanel slots, which a plain
39
+ // shallow stub would swallow — render both the header-actions slot
40
+ // (the toggle) and the body.
41
+ stubs: { CollapsiblePanel: CollapsiblePanelStub },
42
+ },
43
+ });
44
+
45
+ const buttonToggle = (wrapper: ReturnType<typeof mountPanel>) =>
46
+ wrapper
47
+ .findAllComponents(Toggle)
48
+ .find(
49
+ (t) => t.attributes('data-test-header-button-toggle') !== undefined,
50
+ );
51
+
52
+ test('brandsite: the header toggle renders, off, and the fields stay hidden', () => {
53
+ const wrapper = mountPanel(true);
54
+
55
+ expect(buttonToggle(wrapper)).toBeDefined();
56
+ // the stubbed Toggle carries its props as attributes
57
+ expect(buttonToggle(wrapper)?.attributes('model-value')).toBe('false');
58
+ expect(wrapper.findComponent(HeaderButtonSettings).exists()).toBe(false);
59
+ });
60
+
61
+ test('brand page / brand contact: no Button section at all', () => {
62
+ const wrapper = mountPanel(false);
63
+
64
+ expect(buttonToggle(wrapper)).toBeUndefined();
65
+ expect(wrapper.findComponent(HeaderButtonSettings).exists()).toBe(false);
66
+ });
67
+
68
+ test('toggling on writes isVisible to BOTH viewports and reveals the fields', async () => {
69
+ const wrapper = mountPanel(true);
70
+
71
+ await buttonToggle(wrapper)?.vm.$emit('update:model-value', true);
72
+ await wrapper.vm.$nextTick();
73
+
74
+ const sections = useUiPageSettingsStore().sections.header;
75
+
76
+ expect(sections[VIEWPORTS.desktop].headerButton?.isVisible).toBe(true);
77
+ expect(sections[VIEWPORTS.mobile].headerButton?.isVisible).toBe(true);
78
+ expect(wrapper.findComponent(HeaderButtonSettings).exists()).toBe(true);
79
+ });
80
+
81
+ test('expanding the collapsed section via the chevron switches the button on', async () => {
82
+ const wrapper = mountPanel(true);
83
+
84
+ // the Button panel is the last collapsible section
85
+ const panels = wrapper.findAllComponents(CollapsiblePanelStub);
86
+ const buttonPanel = panels[panels.length - 1] as VueWrapper;
87
+
88
+ await buttonPanel.vm.$emit('changed');
89
+ await wrapper.vm.$nextTick();
90
+
91
+ const sections = useUiPageSettingsStore().sections.header;
92
+
93
+ expect(sections[VIEWPORTS.desktop].headerButton?.isVisible).toBe(true);
94
+ expect(sections[VIEWPORTS.mobile].headerButton?.isVisible).toBe(true);
95
+ expect(wrapper.findComponent(HeaderButtonSettings).exists()).toBe(true);
96
+
97
+ // collapsing again must NOT switch it off
98
+ await buttonPanel.vm.$emit('changed');
99
+ await wrapper.vm.$nextTick();
100
+
101
+ expect(sections[VIEWPORTS.desktop].headerButton?.isVisible).toBe(true);
102
+ });
103
+
104
+ test('toggling off hides the fields but keeps the configuration for a re-enable', async () => {
105
+ const wrapper = mountPanel(true);
106
+
107
+ await buttonToggle(wrapper)?.vm.$emit('update:model-value', true);
108
+ // simulate a label edit, which the mixin dual-writes to both viewports
109
+ [VIEWPORTS.desktop, VIEWPORTS.mobile].forEach((viewport) => {
110
+ useUiPageSettingsStore().setPageSettingsSectionValue({
111
+ sectionName: 'header',
112
+ propName: 'headerButton',
113
+ value: { isVisible: true, text: 'Book now', buttonLinkType: 'url' },
114
+ viewport,
115
+ });
116
+ });
117
+ await buttonToggle(wrapper)?.vm.$emit('update:model-value', false);
118
+ await wrapper.vm.$nextTick();
119
+
120
+ const desktop = useUiPageSettingsStore().sections.header[VIEWPORTS.desktop];
121
+
122
+ expect(wrapper.findComponent(HeaderButtonSettings).exists()).toBe(false);
123
+ expect(desktop.headerButton?.isVisible).toBe(false);
124
+ expect(desktop.headerButton?.text).toBe('Book now');
125
+ });
126
+ });
@@ -64,6 +64,7 @@
64
64
  :on-select="handleHeaderDisplayStyleChange"
65
65
  :viewport="viewport"
66
66
  :is-logo-visible="isLogoVisible"
67
+ :show-header-button="isHeaderButtonVisible"
67
68
  />
68
69
 
69
70
  <NavigationStyleSettings
@@ -144,16 +145,29 @@
144
145
  </div>
145
146
  </CollapsiblePanel>
146
147
 
147
- <!-- Button section hidden for now (BP-5854) — re-enable once BP-5852 implements
148
- the header button. Left commented rather than removed since the shell
149
- (CollapsiblePanel + collapsedSections.button state) is already wired up.
150
148
  <CollapsiblePanel
149
+ v-if="isBrandSite"
151
150
  data-component="collapsible-panel"
152
151
  :title="pageSettingsTr('buttonSectionTitle')"
153
152
  :is-collapsed="collapsedSections.button"
154
- @changed="toggleSectionCollapsed('button')"
155
- />
156
- -->
153
+ @changed="onButtonSectionChanged"
154
+ >
155
+ <template #header-actions>
156
+ <Toggle
157
+ id="header-button-visible"
158
+ data-test-header-button-toggle
159
+ :model-value="isHeaderButtonVisible"
160
+ @update:model-value="onHeaderButtonToggle"
161
+ />
162
+ </template>
163
+ <HeaderButtonSettings
164
+ v-if="isHeaderButtonVisible"
165
+ :value="headerButton"
166
+ :is-admin-maker="isAdminMaker"
167
+ :logo-theme="logoTheme"
168
+ @on-change="onHeaderButtonChange"
169
+ />
170
+ </CollapsiblePanel>
157
171
  </div>
158
172
  </template>
159
173
  <script>
@@ -165,6 +179,7 @@ import {
165
179
  Toggle,
166
180
  } from '@designcrowd/fe-shared-lib';
167
181
  import PageSettingsSectionStyleMixin from '../page-settings-section-styles/PageSettingsSectionStyles.mixin.ts';
182
+ import HeaderButtonSettings from '../page-settings-section-styles/HeaderButtonSettings.vue';
168
183
  import HeaderDisplayStyle from '../page-settings-section-styles/HeaderDisplayStyle.vue';
169
184
  import HeaderNavigationType from '../page-settings-section-styles/HeaderNavigationType.vue';
170
185
  import HeaderNavigationStyle from '../page-settings-section-styles/HeaderNavigationStyle.vue';
@@ -183,6 +198,7 @@ export default {
183
198
  Slider,
184
199
  TabMenu,
185
200
  Toggle,
201
+ HeaderButtonSettings,
186
202
  HeaderDisplayStyle,
187
203
  HeaderNavigationType,
188
204
  HeaderNavigationStyle,
@@ -222,6 +238,27 @@ export default {
222
238
  toggleSectionCollapsed(section) {
223
239
  this.collapsedSections[section] = !this.collapsedSections[section];
224
240
  },
241
+ onButtonSectionChanged() {
242
+ const isExpanding = this.collapsedSections.button;
243
+
244
+ this.toggleSectionCollapsed('button');
245
+
246
+ // Expanding while the button is off would show an empty body — opening the
247
+ // section IS the intent to configure it, so switch the button on. Collapsing
248
+ // never switches it off.
249
+ if (isExpanding && !this.isHeaderButtonVisible) {
250
+ this.onHeaderButtonChange({ isVisible: true });
251
+ }
252
+ },
253
+ onHeaderButtonToggle(isVisible) {
254
+ this.onHeaderButtonChange({ isVisible });
255
+
256
+ // Enabling the button is what makes the section worth looking at — open it
257
+ // so the fields appear with the flick of the toggle (matches the design).
258
+ if (isVisible) {
259
+ this.collapsedSections.button = false;
260
+ }
261
+ },
225
262
  },
226
263
  };
227
264
  </script>
@@ -0,0 +1,167 @@
1
+ import { describe, test, expect, beforeEach } from 'vitest';
2
+ import { createPinia, setActivePinia } from 'pinia';
3
+ import { shallowMount } from '@vue/test-utils';
4
+ import type { VueWrapper } from '@vue/test-utils';
5
+ import type { HeaderButton, LogoTheme } from '@brand-page';
6
+ import HeaderButtonSettings from './HeaderButtonSettings.vue';
7
+ import { useUiPageSettingsStore } from '../../store';
8
+ import { useUiStyleSettingsStore } from '../../../style-settings/store/style-settings-store';
9
+ import { availableButtonStyleConfigs } from '@brand-page-maker/models';
10
+ import type { ContentPage } from '../../models';
11
+
12
+ // A string selector types as WrapperLike, which hides `vm` — the fields are
13
+ // located by their data-test hook rather than by component identity (two of
14
+ // them are ColorFields).
15
+ const emitFrom = (
16
+ wrapper: VueWrapper,
17
+ hook: string,
18
+ payload: unknown,
19
+ ): void => {
20
+ (wrapper.findComponent(hook) as unknown as VueWrapper).vm.$emit(
21
+ 'update:modelValue',
22
+ payload,
23
+ );
24
+ };
25
+
26
+ // BP-5852: the Button panel is a toggle plus the fields it gates. Everything
27
+ // below the toggle renders only while it's on, and every edit leaves through
28
+ // one `on-change` patch (the mixin dual-writes it to both viewports).
29
+ describe('HeaderButtonSettings', () => {
30
+ beforeEach(() => {
31
+ setActivePinia(createPinia());
32
+ });
33
+
34
+ const mountPanel = (value?: HeaderButton) =>
35
+ shallowMount(HeaderButtonSettings, {
36
+ props: {
37
+ value,
38
+ isAdminMaker: false,
39
+ logoTheme: {} as LogoTheme,
40
+ },
41
+ });
42
+
43
+ const enabled: HeaderButton = {
44
+ isVisible: true,
45
+ text: '',
46
+ buttonLinkType: 'url',
47
+ };
48
+
49
+ const fieldHooks = [
50
+ '[data-test-header-button-text]',
51
+ '[data-test-header-button-link-type]',
52
+ '[data-test-header-button-text-style]',
53
+ '[data-test-header-button-fill-color]',
54
+ ];
55
+
56
+ // The show/hide toggle lives in the panel HEADER (HeaderSettingsPanel), which
57
+ // also gates this whole component — so every field renders unconditionally here.
58
+ test('every field renders', () => {
59
+ const wrapper = mountPanel(enabled);
60
+
61
+ fieldHooks.forEach((hook) => {
62
+ expect(wrapper.find(hook).exists()).toBe(true);
63
+ });
64
+ });
65
+
66
+ // Border colour is capability-gated on the site's selected button style, like the
67
+ // global Style panel: a style without a border would make the control inert.
68
+ test('the border colour field is hidden while the selected button style draws no border', () => {
69
+ // default selectedButtonStyleId is BUTTON_STYLE_1, whose options carry no border
70
+ const wrapper = mountPanel(enabled);
71
+
72
+ expect(
73
+ wrapper.find('[data-test-header-button-border-color]').exists(),
74
+ ).toBe(false);
75
+ });
76
+
77
+ test('the border colour field renders when the selected button style has a border', () => {
78
+ useUiStyleSettingsStore().$patch(($state) => {
79
+ // availableButtonStyleConfigs starts empty until the store initialises
80
+ $state.availableButtonStyleConfigs = availableButtonStyleConfigs;
81
+ $state.buttonStyles.selectedButtonStyleId = 'BUTTON_STYLE_4';
82
+ });
83
+
84
+ const wrapper = mountPanel(enabled);
85
+
86
+ expect(
87
+ wrapper.find('[data-test-header-button-border-color]').exists(),
88
+ ).toBe(true);
89
+ });
90
+
91
+ test('the link-value field follows the selected link kind', () => {
92
+ const kinds = {
93
+ url: '[data-test-header-button-url]',
94
+ page: '[data-test-header-button-page]',
95
+ mailTo: '[data-test-header-button-email]',
96
+ tel: '[data-test-header-button-tel]',
97
+ } as const;
98
+
99
+ Object.entries(kinds).forEach(([kind, hook]) => {
100
+ const wrapper = mountPanel({
101
+ ...enabled,
102
+ buttonLinkType: kind as HeaderButton['buttonLinkType'],
103
+ });
104
+
105
+ Object.values(kinds).forEach((otherHook) => {
106
+ expect(wrapper.find(otherHook).exists()).toBe(otherHook === hook);
107
+ });
108
+ });
109
+ });
110
+
111
+ const linkTypeValues = (wrapper: VueWrapper): string[] => {
112
+ const group = wrapper.findComponent(
113
+ '[data-test-header-button-link-type]',
114
+ ) as unknown as { props(name: string): unknown };
115
+
116
+ return (group.props('options') as { value: string }[]).map(
117
+ (option) => option.value,
118
+ );
119
+ };
120
+
121
+ test('the four link kinds are offered in a 2-column group', () => {
122
+ const wrapper = mountPanel(enabled);
123
+
124
+ expect(linkTypeValues(wrapper)).toEqual(['url', 'page', 'mailTo', 'tel']);
125
+ expect(
126
+ wrapper.find('[data-test-header-button-link-type]').attributes('columns'),
127
+ ).toBe('2');
128
+ });
129
+
130
+ test('a single-page site drops the Page kind (buttonLinkStyleConvertor)', () => {
131
+ useUiPageSettingsStore().setPages({
132
+ pages: [{ id: 'page-1', title: 'Home', contents: [] }] as ContentPage[],
133
+ });
134
+
135
+ expect(linkTypeValues(mountPanel(enabled))).toEqual([
136
+ 'url',
137
+ 'mailTo',
138
+ 'tel',
139
+ ]);
140
+ });
141
+
142
+ test('a label edit emits only the text patch', () => {
143
+ const wrapper = mountPanel(enabled);
144
+
145
+ emitFrom(wrapper, '[data-test-header-button-text]', {
146
+ id: 'headerButton',
147
+ name: 'text',
148
+ value: 'Book now',
149
+ });
150
+
151
+ expect(wrapper.emitted('on-change')).toEqual([[{ text: 'Book now' }]]);
152
+ });
153
+
154
+ test('a fill-colour pick emits only the fillColor patch', () => {
155
+ const wrapper = mountPanel(enabled);
156
+
157
+ emitFrom(wrapper, '[data-test-header-button-fill-color]', {
158
+ id: 'fillColor',
159
+ name: 'fillColor',
160
+ value: 'var(--palette-2)',
161
+ });
162
+
163
+ expect(wrapper.emitted('on-change')).toEqual([
164
+ [{ fillColor: 'var(--palette-2)' }],
165
+ ]);
166
+ });
167
+ });