@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
@@ -5,9 +5,16 @@ import {
5
5
  type MailingListContent,
6
6
  type ContactFormContent,
7
7
  type ColorItem,
8
+ type HeaderButton,
8
9
  } from '../models';
9
- import { BRAND_PAGE_TYPES, PageHeaderDesktop, PageStyles } from '../models';
10
10
  import {
11
+ BRAND_PAGE_TYPES,
12
+ BUTTON_LINK_TYPE,
13
+ PageHeaderDesktop,
14
+ PageStyles,
15
+ } from '../models';
16
+ import {
17
+ HEADER_BUTTON_FALLBACK_TEXT,
11
18
  mapToBannerSectionViewModel,
12
19
  mapToContactFormViewModel,
13
20
  mapToImageViewModel,
@@ -1114,3 +1121,141 @@ describe('container-aware text colour defaults (view-model layer)', () => {
1114
1121
  expect(vm.nameStyle?.color).toBe('#00ff00');
1115
1122
  });
1116
1123
  });
1124
+
1125
+ describe('mapToPageHeaderViewModel header button', () => {
1126
+ const headerButton: HeaderButton = {
1127
+ isVisible: true,
1128
+ text: 'Contact Us',
1129
+ buttonLinkType: BUTTON_LINK_TYPE.url,
1130
+ url: 'https://example.com',
1131
+ buttonTextStyle: { textStyleType: 'buttons' },
1132
+ fillColor: '#123456',
1133
+ borderColor: '#654321',
1134
+ };
1135
+
1136
+ describe('headerButtonViewModel', () => {
1137
+ const mapButton = (overrides: Partial<HeaderButton> = {}) =>
1138
+ mapToPageHeaderViewModel(
1139
+ new PageHeaderDesktop({
1140
+ headerButton: { ...headerButton, ...overrides },
1141
+ }),
1142
+ new PageStyles(),
1143
+ [],
1144
+ ).headerButtonViewModel;
1145
+
1146
+ test('is absent when the header has no button at all', () => {
1147
+ const vm = mapToPageHeaderViewModel(
1148
+ new PageHeaderDesktop(),
1149
+ new PageStyles(),
1150
+ [],
1151
+ );
1152
+
1153
+ expect(vm.headerButtonViewModel).toBeUndefined();
1154
+ });
1155
+
1156
+ test('is absent when the button is configured but hidden', () => {
1157
+ expect(mapButton({ isVisible: false })).toBeUndefined();
1158
+ });
1159
+
1160
+ test('carries the authored label', () => {
1161
+ expect(mapButton({ text: 'Book now' })?.title).toBe('Book now');
1162
+ });
1163
+
1164
+ test.each([[''], [' ']])(
1165
+ 'falls back to "Contact Us" for a blank label (%j)',
1166
+ (text) => {
1167
+ expect(mapButton({ text })?.title).toBe(HEADER_BUTTON_FALLBACK_TEXT);
1168
+ },
1169
+ );
1170
+
1171
+ // Button.vue reads the link kind off `role` for non-section content, so the
1172
+ // authored link type has to land on the role/clickActionField pair.
1173
+ test.each([
1174
+ [BUTTON_LINK_TYPE.url, 'general-link', 'url'],
1175
+ [BUTTON_LINK_TYPE.page, 'general-link', 'page'],
1176
+ [BUTTON_LINK_TYPE.mailTo, 'email', 'url'],
1177
+ [BUTTON_LINK_TYPE.tel, 'phone', 'url'],
1178
+ ])(
1179
+ 'maps the %s link type to role %s / clickActionField %s',
1180
+ (buttonLinkType, role, clickActionField) => {
1181
+ const vm = mapButton({ buttonLinkType });
1182
+
1183
+ expect(vm?.role).toBe(role);
1184
+ expect(vm?.clickActionField).toBe(clickActionField);
1185
+ },
1186
+ );
1187
+
1188
+ test('routes a page link through the picked page id', () => {
1189
+ const vm = mapButton({
1190
+ buttonLinkType: BUTTON_LINK_TYPE.page,
1191
+ buttonPageId: 'page-2',
1192
+ url: undefined,
1193
+ });
1194
+
1195
+ expect(vm?.page).toBe('page-2');
1196
+ expect(vm?.pageSlug).toBe('page-2');
1197
+ });
1198
+
1199
+ test('overrides the theme fill and border with the authored colours', () => {
1200
+ const vm = mapButton({ fillColor: '#123456', borderColor: '#654321' });
1201
+
1202
+ expect(vm?.styles.style.backgroundColor).toBe('#123456');
1203
+ expect(vm?.styles.style.borderColor).toBe('#654321');
1204
+ });
1205
+
1206
+ test('keeps the site button style when no colours are authored', () => {
1207
+ const styles = new PageStyles();
1208
+ const vm = mapToPageHeaderViewModel(
1209
+ new PageHeaderDesktop({
1210
+ headerButton: {
1211
+ ...headerButton,
1212
+ fillColor: undefined,
1213
+ borderColor: undefined,
1214
+ },
1215
+ }),
1216
+ styles,
1217
+ [],
1218
+ ).headerButtonViewModel;
1219
+
1220
+ expect(vm?.styles.style.backgroundColor).toBe(
1221
+ styles.buttonStyles.style.backgroundColor,
1222
+ );
1223
+ expect(vm?.styles.selectedButtonStyleId).toBe(
1224
+ styles.buttonStyles.selectedButtonStyleId,
1225
+ );
1226
+ });
1227
+
1228
+ test('resolves the button text style against the site text styles', () => {
1229
+ const styles = new PageStyles();
1230
+
1231
+ styles.textStyles.textStyleOptions.buttons.style.fontFamily = 'Lato';
1232
+
1233
+ const vm = mapToPageHeaderViewModel(
1234
+ new PageHeaderDesktop({ headerButton }),
1235
+ styles,
1236
+ [],
1237
+ ).headerButtonViewModel;
1238
+
1239
+ expect(vm?.titleStyle?.fontFamily).toBe('Lato');
1240
+ });
1241
+
1242
+ // The maker writes buttonTextStyle only once the author touches the style field,
1243
+ // so an untouched button still has to inherit the site's button text style.
1244
+ test('defaults to the site button text style when none is authored', () => {
1245
+ const styles = new PageStyles();
1246
+
1247
+ styles.textStyles.textStyleOptions.buttons.style.fontFamily = 'Lato';
1248
+
1249
+ const vm = mapToPageHeaderViewModel(
1250
+ new PageHeaderDesktop({
1251
+ headerButton: { ...headerButton, buttonTextStyle: undefined },
1252
+ }),
1253
+ styles,
1254
+ [],
1255
+ ).headerButtonViewModel;
1256
+
1257
+ expect(vm?.titleStyle?.textStyleType).toBe('buttons');
1258
+ expect(vm?.titleStyle?.fontFamily).toBe('Lato');
1259
+ });
1260
+ });
1261
+ });
@@ -44,8 +44,11 @@ import type {
44
44
  NavRoute,
45
45
  NavOrder,
46
46
  ColorItem,
47
+ HeaderButton,
48
+ ButtonLinkType,
49
+ ContentType,
47
50
  } from '../models';
48
- import { ButtonStyles, VIEWPORTS } from '../models';
51
+ import { ButtonStyles, TEXT_STYLE_TYPES, VIEWPORTS } from '../models';
49
52
  import {
50
53
  NavigationMobile,
51
54
  NavigationDesktop,
@@ -1189,6 +1192,69 @@ export const mapToPageStyleViewModel = (
1189
1192
  };
1190
1193
  };
1191
1194
 
1195
+ // Must stay in step with the maker's `pageSettingsSection.buttonLabelPlaceholder`
1196
+ // (page-settings-section.json) — the placeholder shows what an empty label publishes as.
1197
+ export const HEADER_BUTTON_FALLBACK_TEXT = 'Contact Us';
1198
+
1199
+ /**
1200
+ * `Button.vue` reads the link kind off `role` for every non-section role, so the header
1201
+ * CTA's authored `buttonLinkType` is expressed as the role (+ `clickActionField` for a
1202
+ * page link) that component already resolves — no new branch in the button itself.
1203
+ */
1204
+ const HEADER_BUTTON_LINK_ROLES: Record<ButtonLinkType, ContentType> = {
1205
+ [BUTTON_LINK_TYPE.url]: 'general-link',
1206
+ [BUTTON_LINK_TYPE.page]: 'general-link',
1207
+ [BUTTON_LINK_TYPE.mailTo]: 'email',
1208
+ [BUTTON_LINK_TYPE.tel]: 'phone',
1209
+ };
1210
+
1211
+ const mapToHeaderButtonViewModel = (
1212
+ headerButton: HeaderButton | undefined,
1213
+ styles: PageStyles,
1214
+ ): ButtonViewModel | undefined => {
1215
+ if (!headerButton?.isVisible) {
1216
+ return undefined;
1217
+ }
1218
+
1219
+ const titleStyle: TextStyleDeclaration = headerButton.buttonTextStyle ?? {
1220
+ textStyleType: TEXT_STYLE_TYPES.buttons,
1221
+ };
1222
+
1223
+ const viewModel = mapToButtonViewModel(
1224
+ {
1225
+ id: 'page-header-button',
1226
+ type: HEADER_BUTTON_LINK_ROLES[headerButton.buttonLinkType],
1227
+ iconUrl: '',
1228
+ modalImageUrl: '',
1229
+ colorReplacements: [],
1230
+ title: headerButton.text?.trim() || HEADER_BUTTON_FALLBACK_TEXT,
1231
+ titleStyle,
1232
+ url: headerButton.url ?? '',
1233
+ page: headerButton.buttonPageId ?? '',
1234
+ clickActionField:
1235
+ headerButton.buttonLinkType === BUTTON_LINK_TYPE.page ? 'page' : 'url',
1236
+ },
1237
+ styles,
1238
+ { titleStyle },
1239
+ );
1240
+
1241
+ return {
1242
+ ...viewModel,
1243
+ styles: {
1244
+ ...viewModel.styles,
1245
+ style: {
1246
+ ...viewModel.styles.style,
1247
+ ...(headerButton.fillColor
1248
+ ? { backgroundColor: headerButton.fillColor }
1249
+ : {}),
1250
+ ...(headerButton.borderColor
1251
+ ? { borderColor: headerButton.borderColor }
1252
+ : {}),
1253
+ },
1254
+ },
1255
+ };
1256
+ };
1257
+
1192
1258
  export const mapToPageHeaderViewModel = (
1193
1259
  pageHeader: PageHeaderDesktop | PageHeaderMobile,
1194
1260
  pageStyles: PageStyles,
@@ -1283,6 +1349,10 @@ export const mapToPageHeaderViewModel = (
1283
1349
  }),
1284
1350
  style: pageHeader.style,
1285
1351
  backgroundStyle: getHeaderBackgroundStyleWithOpacity(pageHeader),
1352
+ headerButtonViewModel: mapToHeaderButtonViewModel(
1353
+ pageHeader.headerButton,
1354
+ pageStyles,
1355
+ ),
1286
1356
  };
1287
1357
  };
1288
1358
 
@@ -259,11 +259,14 @@ export interface PageHeaderViewModel extends Omit<
259
259
  | 'logoTemplateImage'
260
260
  | 'logoImageHeight'
261
261
  | 'logoImageWidth'
262
+ | 'headerButton'
262
263
  | 'backgroundStyle'
263
264
  > {
264
265
  logo: LogoViewModel;
265
266
  pages: PageViewModel[];
266
267
  backgroundStyle: BrandPageCssStyle;
268
+ /** Absent unless the header call-to-action button is configured AND visible. */
269
+ headerButtonViewModel?: ButtonViewModel;
267
270
  }
268
271
 
269
272
  export interface PageFooterViewModel extends Omit<
@@ -1867,7 +1867,13 @@
1867
1867
  "iconStyle": "Icon style",
1868
1868
  "logoSize": "Logo Size",
1869
1869
  "socialLinks": "Social Links",
1870
- "copyright": "Copyright"
1870
+ "copyright": "Copyright",
1871
+ "buttonLabel": "Label",
1872
+ "buttonLabelPlaceholder": "Contact Us",
1873
+ "buttonLink": "Link",
1874
+ "buttonPhonePlaceholder": "12345-6789",
1875
+ "buttonFillColor": "Fill color",
1876
+ "buttonBorderColor": "Border color"
1871
1877
  },
1872
1878
  "publishBrandPageModalWithState": {
1873
1879
  "domainSearchButtonLabel": "Search",
package/src/lib.ts CHANGED
@@ -126,6 +126,7 @@ export {
126
126
  useRouteStore,
127
127
  } from './brand-page-maker/stores';
128
128
  export { useShopConfigStore } from './shared/ecommerce-settings/store/shop-settings-store';
129
+ export { refreshDuplicatedMetadata } from './shared/ecommerce-settings/util/refreshDuplicatedMetadata';
129
130
 
130
131
  // for jest, need to set active pinia in library.brand-page as well
131
132
  import { createPinia, setActivePinia } from 'pinia';
@@ -0,0 +1,399 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { refreshDuplicatedMetadata } from './refreshDuplicatedMetadata';
3
+ import type { BrandPageModel } from '@brand-page/models';
4
+ import type { ProductModel } from '@brand-page/models/shop.model';
5
+
6
+ const variantProduct = (): ProductModel => ({
7
+ id: 'p1',
8
+ name: 'Denim Jacket',
9
+ description: '',
10
+ imageUrl: '',
11
+ defaultPrice: 10,
12
+ hasVariants: true,
13
+ enableNotes: false,
14
+ variant: [
15
+ {
16
+ id: 'v1',
17
+ name: 'Colour',
18
+ options: [
19
+ { id: 'o1', name: 'Black', price: 10 },
20
+ { id: 'o2', name: 'White', price: 12 },
21
+ ],
22
+ },
23
+ ],
24
+ skus: [
25
+ {
26
+ id: 's1',
27
+ price: 10,
28
+ skuKey: 'denim-jacket-o1-Black-abc123',
29
+ variant: [
30
+ {
31
+ id: 'v1',
32
+ name: 'Colour',
33
+ option: { id: 'o1', name: 'Black', price: 10 },
34
+ },
35
+ ],
36
+ },
37
+ {
38
+ id: 's2',
39
+ price: 12,
40
+ skuKey: 'denim-jacket-o2-White-def456',
41
+ variant: [
42
+ {
43
+ id: 'v1',
44
+ name: 'Colour',
45
+ option: { id: 'o2', name: 'White', price: 12 },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ });
51
+
52
+ const noVariantProduct = (): ProductModel => ({
53
+ id: 'p2',
54
+ name: 'Sticker',
55
+ description: '',
56
+ imageUrl: '',
57
+ defaultPrice: 5,
58
+ hasVariants: false,
59
+ enableNotes: false,
60
+ variant: [],
61
+ skus: [{ id: 's3', price: 5, skuKey: 'sticker-nv-zzz999', variant: [] }],
62
+ });
63
+
64
+ const multiVariantProduct = (): ProductModel => ({
65
+ id: 'mp1',
66
+ name: 'Tee',
67
+ description: '',
68
+ imageUrl: '',
69
+ defaultPrice: 20,
70
+ hasVariants: true,
71
+ enableNotes: false,
72
+ variant: [
73
+ {
74
+ id: 'cv',
75
+ name: 'Colour',
76
+ options: [
77
+ { id: 'c-black', name: 'Black', price: 20 },
78
+ { id: 'c-white', name: 'White', price: 20 },
79
+ ],
80
+ },
81
+ {
82
+ id: 'sv',
83
+ name: 'Size',
84
+ options: [
85
+ { id: 's-s', name: 'S', price: 20 },
86
+ { id: 's-m', name: 'M', price: 22 },
87
+ ],
88
+ },
89
+ ],
90
+ skus: [
91
+ {
92
+ id: 'k1',
93
+ price: 20,
94
+ skuKey: 'tee-c-black-Black-a',
95
+ variant: [
96
+ {
97
+ id: 'cv',
98
+ name: 'Colour',
99
+ option: { id: 'c-black', name: 'Black', price: 20 },
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ id: 'k2',
105
+ price: 20,
106
+ skuKey: 'tee-c-white-White-b',
107
+ variant: [
108
+ {
109
+ id: 'cv',
110
+ name: 'Colour',
111
+ option: { id: 'c-white', name: 'White', price: 20 },
112
+ },
113
+ ],
114
+ },
115
+ {
116
+ id: 'k3',
117
+ price: 20,
118
+ skuKey: 'tee-s-s-S-c',
119
+ variant: [
120
+ { id: 'sv', name: 'Size', option: { id: 's-s', name: 'S', price: 20 } },
121
+ ],
122
+ },
123
+ {
124
+ id: 'k4',
125
+ price: 22,
126
+ skuKey: 'tee-s-m-M-d',
127
+ variant: [
128
+ { id: 'sv', name: 'Size', option: { id: 's-m', name: 'M', price: 22 } },
129
+ ],
130
+ },
131
+ ],
132
+ });
133
+
134
+ const paymentConfigStub = () => ({
135
+ id: 'cfg-1',
136
+ name: "Seller's Stripe",
137
+ status: 'active',
138
+ defaultCurrency: 'USD',
139
+ defaultCurrencySymbol: '$',
140
+ paymentProviderName: 'stripe',
141
+ paymentProviderImageUrl: '',
142
+ dashboardUrl: 'https://dashboard.stripe.com/x',
143
+ });
144
+
145
+ const requestPaymentContent = () => ({
146
+ type: 'request-payment',
147
+ id: 'rp1',
148
+ requestPaymentConfig: {
149
+ paymentConfig: paymentConfigStub(),
150
+ paymentOptions: [
151
+ { id: 'po1', amount: '10', name: 'Small' },
152
+ { id: 'po2', amount: '20', name: 'Large' },
153
+ ],
154
+ },
155
+ });
156
+
157
+ const supportMeContent = () => ({
158
+ type: 'support-me',
159
+ id: 'sm1',
160
+ supportMeConfig: {
161
+ paymentConfig: paymentConfigStub(),
162
+ paymentOptions: [{ id: 'so1', amount: '5', name: 'Coffee' }],
163
+ },
164
+ });
165
+
166
+ const shopWithConfig = (products: ProductModel[]) => ({
167
+ type: 'shop-section',
168
+ id: 'shop1',
169
+ products,
170
+ shopPaymentConfig: {
171
+ paymentConfig: paymentConfigStub(),
172
+ paymentOptions: [],
173
+ },
174
+ });
175
+
176
+ const modelWith = (contents: unknown[]): BrandPageModel =>
177
+ ({ pages: [{ contents }] }) as unknown as BrandPageModel;
178
+
179
+ const shopModel = (products: ProductModel[]): BrandPageModel =>
180
+ modelWith([{ type: 'shop-section', products }]);
181
+
182
+ const collectIds = (products: ProductModel[]): string[] =>
183
+ products.flatMap((p) => [
184
+ p.id,
185
+ ...p.variant.flatMap((v) => [v.id, ...v.options.map((o) => o.id)]),
186
+ ...p.skus.flatMap((s) => [
187
+ s.id,
188
+ ...s.variant.flatMap((sv) => [sv.id, sv.option.id]),
189
+ ]),
190
+ ]);
191
+
192
+ describe('refreshDuplicatedMetadata — shop id rotation', () => {
193
+ it('R1 — rotates every product/sku/variant/option id (no input id survives)', () => {
194
+ const products = [variantProduct(), noVariantProduct()];
195
+ const before = new Set(collectIds(products));
196
+ const beforeKeys = new Set(
197
+ products.flatMap((p) => p.skus.map((s) => s.skuKey)),
198
+ );
199
+
200
+ const out = shopModel(products);
201
+
202
+ refreshDuplicatedMetadata(out);
203
+ const after = collectIds(products);
204
+
205
+ expect(after.every((id) => !before.has(id))).toBe(true);
206
+ products
207
+ .flatMap((p) => p.skus.map((s) => s.skuKey))
208
+ .forEach((key) => expect(beforeKeys.has(key)).toBe(false));
209
+ });
210
+
211
+ it('R2 — preserves the SKU→option linkage (each sku option id maps to a product option id)', () => {
212
+ const products = [variantProduct()];
213
+
214
+ refreshDuplicatedMetadata(shopModel(products));
215
+
216
+ products.forEach((p) => {
217
+ const productOptionIds = new Set(
218
+ p.variant.flatMap((v) => v.options.map((o) => o.id)),
219
+ );
220
+
221
+ p.skus.forEach((s) =>
222
+ s.variant.forEach((sv) =>
223
+ expect(productOptionIds.has(sv.option.id)).toBe(true),
224
+ ),
225
+ );
226
+ });
227
+ });
228
+
229
+ it('R3 — regenerates each skuKey to a fresh value that embeds the new option id', () => {
230
+ const product = variantProduct();
231
+
232
+ refreshDuplicatedMetadata(shopModel([product]));
233
+
234
+ product.skus.forEach((s) => {
235
+ const newOptionId = s.variant[0].option.id;
236
+
237
+ expect(s.skuKey).toContain(newOptionId);
238
+ expect(s.skuKey.startsWith('denim-jacket-')).toBe(true);
239
+ });
240
+ // The two SKUs still get distinct keys.
241
+ expect(product.skus[0].skuKey).not.toBe(product.skus[1].skuKey);
242
+ });
243
+
244
+ it('R4 — no-variant product keeps the "nv" skuKey shape with a fresh id', () => {
245
+ const product = noVariantProduct();
246
+
247
+ refreshDuplicatedMetadata(shopModel([product]));
248
+
249
+ const sku = product.skus[0];
250
+
251
+ expect(sku.id).not.toBe('s3');
252
+ expect(sku.skuKey).toBe(`sticker-nv-${sku.skuKey.split('-').pop()}`);
253
+ expect(sku.skuKey).not.toBe('sticker-nv-zzz999');
254
+ });
255
+
256
+ it('R5 — no-op when there is no shop content (and does not throw)', () => {
257
+ const model = modelWith([{ type: 'text', text: 'hello' }]);
258
+
259
+ expect(() => refreshDuplicatedMetadata(model)).not.toThrow();
260
+
261
+ // An empty shop section is also a clean no-op.
262
+ const emptyShop = shopModel([]);
263
+
264
+ expect(() => refreshDuplicatedMetadata(emptyShop)).not.toThrow();
265
+ });
266
+
267
+ it('R6 — multi-variant product: every SKU still links to a product option after rotation', () => {
268
+ const product = multiVariantProduct();
269
+
270
+ refreshDuplicatedMetadata(shopModel([product]));
271
+
272
+ const productOptionIds = new Set(
273
+ product.variant.flatMap((v) => v.options.map((o) => o.id)),
274
+ );
275
+
276
+ expect(productOptionIds.size).toBe(4);
277
+ product.skus.forEach((s) =>
278
+ s.variant.forEach((sv) =>
279
+ expect(productOptionIds.has(sv.option.id)).toBe(true),
280
+ ),
281
+ );
282
+ });
283
+
284
+ it('R7 — rotates shop content across multiple sections and pages with no id collisions', () => {
285
+ const p1 = variantProduct();
286
+ const p2 = variantProduct();
287
+
288
+ const model = {
289
+ pages: [
290
+ {
291
+ contents: [
292
+ { type: 'shop-section', products: [p1] },
293
+ { type: 'text', text: 'x' },
294
+ ],
295
+ },
296
+ { contents: [{ type: 'shop-section', products: [p2] }] },
297
+ ],
298
+ } as unknown as BrandPageModel;
299
+
300
+ refreshDuplicatedMetadata(model);
301
+
302
+ // Two products from identical fixtures must share no id across sections/pages. (Within a
303
+ // product, a SKU's option id equals its product option id by design — the R2 linkage — so
304
+ // the check is cross-product disjointness, not global uniqueness.)
305
+ const ids1 = new Set(collectIds([p1]));
306
+ const p2Ids = collectIds([p2]);
307
+
308
+ expect(p2Ids.some((id) => ids1.has(id))).toBe(false);
309
+ });
310
+ });
311
+
312
+ describe('refreshDuplicatedMetadata — payment connection reset', () => {
313
+ it('P1 — request-payment: rotates the content id and every option id, and drops the connection', () => {
314
+ const content = requestPaymentContent();
315
+
316
+ refreshDuplicatedMetadata(modelWith([content]));
317
+
318
+ // Content id (content_block_payment_config PK) and option ids (brand_page_product PK) are the
319
+ // global keys a reconnect would otherwise clash the source on — both rotated.
320
+ expect(content.id).not.toBe('rp1');
321
+ expect(
322
+ content.requestPaymentConfig.paymentOptions.map((o) => o.id),
323
+ ).not.toContain('po1');
324
+ expect(
325
+ content.requestPaymentConfig.paymentOptions.map((o) => o.id),
326
+ ).not.toContain('po2');
327
+ // Amounts/labels are kept — only the ids change and the provider is disconnected.
328
+ expect(
329
+ content.requestPaymentConfig.paymentOptions.map((o) => o.amount),
330
+ ).toEqual(['10', '20']);
331
+ expect(content.requestPaymentConfig.paymentConfig).toBeUndefined();
332
+ });
333
+
334
+ it('P2 — support-me: rotates the content id and option ids, and drops the connection', () => {
335
+ const content = supportMeContent();
336
+
337
+ refreshDuplicatedMetadata(modelWith([content]));
338
+
339
+ expect(content.id).not.toBe('sm1');
340
+ expect(content.supportMeConfig.paymentOptions[0].id).not.toBe('so1');
341
+ expect(content.supportMeConfig.paymentOptions[0].amount).toBe('5');
342
+ expect(content.supportMeConfig.paymentConfig).toBeUndefined();
343
+ });
344
+
345
+ it('P3 — shop: drops the checkout connection but keeps (rotated) products and the content id', () => {
346
+ const product = variantProduct();
347
+ const content = shopWithConfig([product]);
348
+
349
+ refreshDuplicatedMetadata(modelWith([content]));
350
+
351
+ // The seller's Stripe connection is removed…
352
+ expect(content.shopPaymentConfig.paymentConfig).toBeUndefined();
353
+ // …but products are still rotated (shop collides on product ids, not the content id)…
354
+ expect(product.id).not.toBe('p1');
355
+ // …and the shop content id is deliberately left alone (its config link uses throwaway ids).
356
+ expect(content.id).toBe('shop1');
357
+ });
358
+
359
+ it('P4 — mixed page: each payment block is reset, non-payment content is untouched', () => {
360
+ const rp = requestPaymentContent();
361
+ const sm = supportMeContent();
362
+ const shop = shopWithConfig([variantProduct()]);
363
+ const text = { type: 'text', id: 'txt1', text: 'hello' };
364
+
365
+ expect(() =>
366
+ refreshDuplicatedMetadata(modelWith([rp, sm, shop, text])),
367
+ ).not.toThrow();
368
+
369
+ expect(rp.requestPaymentConfig.paymentConfig).toBeUndefined();
370
+ expect(sm.supportMeConfig.paymentConfig).toBeUndefined();
371
+ expect(shop.shopPaymentConfig.paymentConfig).toBeUndefined();
372
+ expect(text).toEqual({ type: 'text', id: 'txt1', text: 'hello' });
373
+ });
374
+
375
+ it('P5 — already-disconnected payment block: still rotates ids and stays disconnected', () => {
376
+ const content = requestPaymentContent();
377
+
378
+ content.requestPaymentConfig.paymentConfig = undefined as never;
379
+
380
+ expect(() => refreshDuplicatedMetadata(modelWith([content]))).not.toThrow();
381
+
382
+ expect(content.id).not.toBe('rp1');
383
+ expect(content.requestPaymentConfig.paymentOptions[0].id).not.toBe('po1');
384
+ expect(content.requestPaymentConfig.paymentConfig).toBeUndefined();
385
+ });
386
+
387
+ it('P6 — clears the site-level paymentConfig (the authoritative checkout connection)', () => {
388
+ // initialiseShopStore prefers model.paymentConfig over any content-level config, so leaving it
389
+ // would still route the copy's checkout to the source owner even with the shop section cleared.
390
+ const model = {
391
+ paymentConfig: paymentConfigStub(),
392
+ pages: [{ contents: [shopWithConfig([variantProduct()])] }],
393
+ } as unknown as BrandPageModel;
394
+
395
+ refreshDuplicatedMetadata(model);
396
+
397
+ expect(model.paymentConfig).toBeUndefined();
398
+ });
399
+ });