@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.
- package/lib/src/{CreatedOnBrandCrowd-de-de-CmNhDMIf.js → CreatedOnBrandCrowd-de-de-Cwq9enJs.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-DgplxxWa.js → CreatedOnBrandCrowd-en-us-ILK04gFj.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-DFdhFOa1.js → CreatedOnBrandCrowd-es-es-CbjxLxtu.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-DjLCWBLB.js → CreatedOnBrandCrowd-fr-ca-BwBDiK9z.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-DjLCWBLB.js → CreatedOnBrandCrowd-fr-fr-BwBDiK9z.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-CI2XD0OS.js → CreatedOnBrandCrowd-pt-br-DvhJROpG.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-CI2XD0OS.js → CreatedOnBrandCrowd-pt-pt-DvhJROpG.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-CkLkBztx.js → CreatedOnDcom-de-de-BK9imWOh.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-frnxBJJv.js → CreatedOnDcom-en-us-CyH-_qz1.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-CrqgiERj.js → CreatedOnDcom-es-es-Dh51qYEA.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-CPoEcusj.js → CreatedOnDcom-fr-ca-BArRxwWX.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-BqrRH3dV.js → CreatedOnDcom-fr-fr-CdglI-ZB.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-CpbVi3Sw.js → CreatedOnDcom-pt-br-DCehns-G.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-s0XZ3c7y.js → CreatedOnDcom-pt-pt-B8hl-oew.js} +1 -1
- package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.d.ts +27 -2
- package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.js +26 -0
- package/lib/src/brand-page/components/page-header/PageHeader.mixin.d.ts +3 -1
- package/lib/src/brand-page/components/page-header/PageHeader.mixin.js +12 -0
- package/lib/src/brand-page/models/index.d.ts +12 -0
- package/lib/src/brand-page/models/index.js +3 -0
- package/lib/src/brand-page/utils/button.util.d.ts +5 -0
- package/lib/src/brand-page/utils/button.util.js +4 -0
- package/lib/src/brand-page/utils/view-model.util.d.ts +1 -0
- package/lib/src/brand-page/utils/view-model.util.js +51 -1
- package/lib/src/brand-page/view-models.d.ts +3 -1
- package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +1 -1
- package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +1 -1
- package/lib/src/brand-page-maker/stores/ai-checkpoint/ai-checkpoint-store.d.ts +1008 -0
- package/lib/src/{bundled-translations-B2q5GWTQ.js → bundled-translations-B8fAEel0.js} +1 -1
- package/lib/src/bundles/bundled-translations.json +7 -1
- package/lib/src/css/library.brand-page-brandCrowd.min.css +315 -122
- package/lib/src/css/library.brand-page-brandPage.min.css +315 -122
- package/lib/src/css/library.brand-page-designCom.min.css +315 -122
- package/lib/src/css/library.brand-page-preview.min.css +315 -122
- package/lib/src/index.d.ts +1 -0
- package/lib/src/index.mjs +45 -44
- package/lib/src/{lib-O_eJgJHP.js → lib-KFjPFfxn.js} +31522 -31118
- package/lib/src/lib.js +1 -0
- package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +1 -1
- package/lib/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.d.ts +11 -0
- package/lib/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.js +80 -0
- package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +1 -1
- package/lib/src/shared/page-settings/components/delete-page-modal/DeletePageModal.mixin.d.ts +1 -1
- package/lib/src/shared/page-settings/components/new-page-modal/NewPageModal.mixin.d.ts +1 -1
- package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +113 -1
- package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +117 -2
- package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.js +43 -1
- package/lib/src/shared/page-settings/store/page-settings-store.d.ts +2 -2
- package/package.json +1 -1
- package/src/brand-page/components/button/Button.vue +35 -8
- package/src/brand-page/components/button/Button.vue.test.ts +59 -0
- package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.ts +29 -1
- package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue +36 -0
- package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue.test.ts +175 -0
- package/src/brand-page/components/page/Page.vue +6 -0
- package/src/brand-page/components/page-header/HeaderLayout.vue +198 -4
- package/src/brand-page/components/page-header/PageHeader.mixin.ts +16 -1
- package/src/brand-page/components/page-header/PageHeader.vue +23 -1
- package/src/brand-page/components/page-header/PageHeader.vue.test.ts +378 -0
- package/src/brand-page/models/header-button.model.test.ts +86 -0
- package/src/brand-page/models/index.ts +16 -0
- package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +6 -0
- package/src/brand-page/utils/__snapshots__/migration2.util.test.ts.snap +2 -0
- package/src/brand-page/utils/__snapshots__/model.util.test.ts.snap +4 -0
- package/src/brand-page/utils/button.util.ts +8 -0
- package/src/brand-page/utils/view-model.util.test.ts +146 -1
- package/src/brand-page/utils/view-model.util.ts +71 -1
- package/src/brand-page/view-models.ts +3 -0
- package/src/bundles/bundled-translations.json +7 -1
- package/src/lib.ts +1 -0
- package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.test.ts +399 -0
- package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.ts +99 -0
- package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.button-section.test.ts +126 -0
- package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.vue +43 -6
- package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.test.ts +167 -0
- package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.vue +237 -0
- package/src/shared/page-settings/components/page-settings-section-styles/HeaderDisplayStyle.test.ts +160 -0
- package/src/shared/page-settings/components/page-settings-section-styles/HeaderDisplayStyle.vue +37 -8
- package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.header-button.test.ts +113 -0
- package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.ts +47 -0
- package/src/shared/page-settings/components/page-settings-section-styles/i18n/page-settings-section.json +7 -1
- 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
|
+
});
|