@shoppexio/builder-runtime 0.1.1 → 0.1.2
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/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/layout.d.ts +3 -8
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +2 -10
- package/dist/preview-fixtures.d.ts +16 -0
- package/dist/preview-fixtures.d.ts.map +1 -0
- package/dist/preview-fixtures.js +40 -0
- package/dist/product-page.d.ts +13 -0
- package/dist/product-page.d.ts.map +1 -0
- package/dist/product-page.js +18 -0
- package/dist/react.d.ts +31 -2
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +122 -42
- package/dist/search-bar-settings.d.ts +33 -0
- package/dist/search-bar-settings.d.ts.map +1 -0
- package/dist/search-bar-settings.js +99 -0
- package/dist/standard-product-blocks.d.ts +48 -0
- package/dist/standard-product-blocks.d.ts.map +1 -0
- package/dist/standard-product-blocks.js +45 -0
- package/dist/standard-product-page.d.ts +69 -0
- package/dist/standard-product-page.d.ts.map +1 -0
- package/dist/standard-product-page.js +89 -0
- package/dist/storefront-google-fonts.d.ts +2 -0
- package/dist/storefront-google-fonts.d.ts.map +1 -0
- package/dist/storefront-google-fonts.js +28 -0
- package/package.json +3 -3
- package/src/builder-runtime.test.ts +33 -0
- package/src/index.ts +4 -0
- package/src/layout.ts +11 -21
- package/src/preview-fixtures.ts +56 -0
- package/src/product-page.test.ts +37 -0
- package/src/product-page.ts +32 -0
- package/src/react-runtime.test.tsx +42 -0
- package/src/react.tsx +214 -45
- package/src/search-bar-settings.test.ts +72 -0
- package/src/search-bar-settings.ts +176 -0
- package/src/standard-product-blocks.test.tsx +93 -0
- package/src/standard-product-blocks.tsx +121 -0
- package/src/standard-product-page.test.ts +171 -0
- package/src/standard-product-page.ts +169 -0
- package/src/storefront-google-fonts.test.ts +31 -0
- package/src/storefront-google-fonts.ts +43 -0
package/src/react.tsx
CHANGED
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
BuilderSettingsSchema,
|
|
4
4
|
PreviewMessageSchema,
|
|
5
5
|
createEmptyBuilderSettings,
|
|
6
|
+
isTrustedPreviewParentOrigin,
|
|
6
7
|
migrateLegacyBuilderSettings,
|
|
8
|
+
normalizeDedicatedPageId,
|
|
7
9
|
} from '@shoppex/builder-contracts';
|
|
8
10
|
import {
|
|
9
11
|
createElement,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
useMemo,
|
|
14
16
|
useRef,
|
|
15
17
|
useState,
|
|
18
|
+
type ComponentPropsWithoutRef,
|
|
16
19
|
type ComponentType,
|
|
17
20
|
type ElementType,
|
|
18
21
|
type ReactNode,
|
|
@@ -24,8 +27,14 @@ import {
|
|
|
24
27
|
getBuilderContentString,
|
|
25
28
|
getBuilderContentValue,
|
|
26
29
|
} from './content.js';
|
|
30
|
+
import { BUILDER_PREVIEW_REVIEWS } from './preview-fixtures.js';
|
|
27
31
|
import { createBuilderCss } from './css-vars.js';
|
|
32
|
+
import { builderBlock, type BuilderAttributeMap } from './attributes.js';
|
|
28
33
|
import { getPageBlocks, getPageLayout, getVisiblePageBlocks } from './layout.js';
|
|
34
|
+
import {
|
|
35
|
+
getNavigationHeaderSettings,
|
|
36
|
+
resolveSearchBarSettings,
|
|
37
|
+
} from './search-bar-settings.js';
|
|
29
38
|
import { resolveStyleSlotValue } from './style-slots.js';
|
|
30
39
|
|
|
31
40
|
type BuilderRuntimeContextValue = {
|
|
@@ -214,6 +223,7 @@ export function BuilderRuntimePreviewProvider({
|
|
|
214
223
|
message: normalized.message,
|
|
215
224
|
...(normalized.stack ? { stack: normalized.stack } : {}),
|
|
216
225
|
source,
|
|
226
|
+
phase: 'runtime',
|
|
217
227
|
diagnostics: {
|
|
218
228
|
name: normalized.name,
|
|
219
229
|
href: window.location.href,
|
|
@@ -435,15 +445,86 @@ export function useVisibleBuilderPageBlocks(pageId: string) {
|
|
|
435
445
|
|
|
436
446
|
export function useThemePageBlocks(pageId: string, defaultOrder: string[]): BlockInstance[] {
|
|
437
447
|
const { settings } = useBuilderRuntime();
|
|
448
|
+
const canonicalPageId = normalizeDedicatedPageId(pageId);
|
|
438
449
|
|
|
439
450
|
return useMemo(() => {
|
|
440
|
-
const page = settings.theme.layout[
|
|
451
|
+
const page = settings.theme.layout[canonicalPageId];
|
|
441
452
|
if (page) {
|
|
442
453
|
return page.blocks.filter((block) => block.visible);
|
|
443
454
|
}
|
|
444
455
|
|
|
445
456
|
return defaultOrder.map((type) => ({ id: type, type, visible: true, settings: {} }));
|
|
446
|
-
}, [
|
|
457
|
+
}, [canonicalPageId, defaultOrder, settings.theme.layout]);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function useThemePageBlockAttributes(
|
|
461
|
+
pageId: string,
|
|
462
|
+
defaultOrder: string[],
|
|
463
|
+
): BuilderAttributeMap {
|
|
464
|
+
const canonicalPageId = normalizeDedicatedPageId(pageId);
|
|
465
|
+
const blocks = useThemePageBlocks(pageId, defaultOrder);
|
|
466
|
+
const block = blocks[0];
|
|
467
|
+
|
|
468
|
+
return useMemo(
|
|
469
|
+
() => ({
|
|
470
|
+
'data-page-id': canonicalPageId,
|
|
471
|
+
...(block ? builderBlock(block.id, block.type) : {}),
|
|
472
|
+
}),
|
|
473
|
+
[block, canonicalPageId],
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
type DedicatedBuilderPageProps<T extends ElementType> = {
|
|
478
|
+
pageId: string;
|
|
479
|
+
defaultBlockOrder: string[];
|
|
480
|
+
as?: T;
|
|
481
|
+
children: ReactNode;
|
|
482
|
+
} & Omit<ComponentPropsWithoutRef<T>, 'as' | 'children'>;
|
|
483
|
+
|
|
484
|
+
export function DedicatedBuilderPage<T extends ElementType = 'section'>({
|
|
485
|
+
pageId,
|
|
486
|
+
defaultBlockOrder,
|
|
487
|
+
as,
|
|
488
|
+
children,
|
|
489
|
+
...rest
|
|
490
|
+
}: DedicatedBuilderPageProps<T>) {
|
|
491
|
+
const attrs = useThemePageBlockAttributes(pageId, defaultBlockOrder);
|
|
492
|
+
const Component = (as ?? 'section') as ElementType;
|
|
493
|
+
|
|
494
|
+
return createElement(Component, { ...attrs, ...rest }, children);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function useBuilderPreviewReviews<T>(input: {
|
|
498
|
+
reviews: T[];
|
|
499
|
+
isLoading: boolean;
|
|
500
|
+
error: unknown;
|
|
501
|
+
fixtures?: T[];
|
|
502
|
+
}): {
|
|
503
|
+
reviews: T[];
|
|
504
|
+
isLoading: boolean;
|
|
505
|
+
error: unknown;
|
|
506
|
+
isUsingFixtures: boolean;
|
|
507
|
+
} {
|
|
508
|
+
const fixtures = input.fixtures ?? (BUILDER_PREVIEW_REVIEWS as T[]);
|
|
509
|
+
const shouldUseFixtures = isBuilderPreviewRuntime() && Boolean(input.error);
|
|
510
|
+
|
|
511
|
+
return useMemo(() => {
|
|
512
|
+
if (!shouldUseFixtures) {
|
|
513
|
+
return {
|
|
514
|
+
reviews: input.reviews,
|
|
515
|
+
isLoading: input.isLoading,
|
|
516
|
+
error: input.error,
|
|
517
|
+
isUsingFixtures: false,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
reviews: fixtures,
|
|
523
|
+
isLoading: false,
|
|
524
|
+
error: null,
|
|
525
|
+
isUsingFixtures: true,
|
|
526
|
+
};
|
|
527
|
+
}, [fixtures, input.error, input.isLoading, input.reviews, shouldUseFixtures]);
|
|
447
528
|
}
|
|
448
529
|
|
|
449
530
|
export function useBuilderStyleSlot(
|
|
@@ -485,7 +566,60 @@ function getNestedBuilderSetting(record: Record<string, unknown>, path: string):
|
|
|
485
566
|
return current;
|
|
486
567
|
}
|
|
487
568
|
|
|
488
|
-
function
|
|
569
|
+
export function useSearchBarSettings(input: {
|
|
570
|
+
variant: 'hero' | 'navigation';
|
|
571
|
+
headerSettings?: Record<string, unknown>;
|
|
572
|
+
}) {
|
|
573
|
+
const runtime = useBuilderRuntime();
|
|
574
|
+
const headerSettings = useMemo(
|
|
575
|
+
() => input.headerSettings ?? getNavigationHeaderSettings(runtime.settings),
|
|
576
|
+
[input.headerSettings, runtime.settings],
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
const heroPlaceholder = useBuilderContentValue('hero.search.placeholder');
|
|
580
|
+
const heroBackground = useBuilderContentValue('hero.search.background');
|
|
581
|
+
const heroBorderColor = useBuilderContentValue('hero.search.borderColor');
|
|
582
|
+
const heroBorderRadius = useBuilderContentValue('hero.search.borderRadius');
|
|
583
|
+
const heroMaxWidth = useBuilderContentValue('hero.search.maxWidth');
|
|
584
|
+
const heroShowShortcut = useBuilderContentValue('hero.search.showShortcut');
|
|
585
|
+
const heroShow = useBuilderContentValue('hero.search.show');
|
|
586
|
+
|
|
587
|
+
return useMemo(
|
|
588
|
+
() =>
|
|
589
|
+
resolveSearchBarSettings({
|
|
590
|
+
variant: input.variant,
|
|
591
|
+
headerSettings,
|
|
592
|
+
heroValues: {
|
|
593
|
+
'hero.search.placeholder': heroPlaceholder,
|
|
594
|
+
'hero.search.background': heroBackground,
|
|
595
|
+
'hero.search.borderColor': heroBorderColor,
|
|
596
|
+
'hero.search.borderRadius': heroBorderRadius,
|
|
597
|
+
'hero.search.maxWidth': heroMaxWidth,
|
|
598
|
+
'hero.search.showShortcut': heroShowShortcut,
|
|
599
|
+
'hero.search.show': heroShow,
|
|
600
|
+
},
|
|
601
|
+
}),
|
|
602
|
+
[
|
|
603
|
+
headerSettings,
|
|
604
|
+
heroBackground,
|
|
605
|
+
heroBorderColor,
|
|
606
|
+
heroBorderRadius,
|
|
607
|
+
heroMaxWidth,
|
|
608
|
+
heroPlaceholder,
|
|
609
|
+
heroShow,
|
|
610
|
+
heroShowShortcut,
|
|
611
|
+
input.variant,
|
|
612
|
+
],
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export {
|
|
617
|
+
buildSearchShellStyle,
|
|
618
|
+
getNavigationHeaderSettings,
|
|
619
|
+
resolveSearchBarSettings,
|
|
620
|
+
} from './search-bar-settings.js';
|
|
621
|
+
|
|
622
|
+
export function isBuilderPreviewRuntime(): boolean {
|
|
489
623
|
if (typeof window === 'undefined') {
|
|
490
624
|
return false;
|
|
491
625
|
}
|
|
@@ -587,7 +721,7 @@ function getPreviewParentOrigin(location: Location, referrer: string): string |
|
|
|
587
721
|
function isTrustedBuilderPreviewEmbed(location: Location, parentOrigin: string | null): boolean {
|
|
588
722
|
if (window.parent === window || !parentOrigin) return false;
|
|
589
723
|
if (!hasBuilderPreviewMode(location)) return false;
|
|
590
|
-
return
|
|
724
|
+
return isTrustedPreviewParentOrigin(parentOrigin);
|
|
591
725
|
}
|
|
592
726
|
|
|
593
727
|
function hasBuilderPreviewMode(location: Location): boolean {
|
|
@@ -599,25 +733,6 @@ function hasBuilderPreviewMode(location: Location): boolean {
|
|
|
599
733
|
);
|
|
600
734
|
}
|
|
601
735
|
|
|
602
|
-
function isTrustedBuilderPreviewParentOrigin(origin: string): boolean {
|
|
603
|
-
try {
|
|
604
|
-
const parsed = new URL(origin);
|
|
605
|
-
const hostname = parsed.hostname.toLowerCase();
|
|
606
|
-
return (
|
|
607
|
-
hostname === 'dashboard.shoppex.io'
|
|
608
|
-
|| hostname === 'dashboard.shoppex.test'
|
|
609
|
-
|| hostname === 'localhost'
|
|
610
|
-
|| hostname === '127.0.0.1'
|
|
611
|
-
|| hostname === '::1'
|
|
612
|
-
|| hostname.endsWith('.localhost')
|
|
613
|
-
|| hostname.endsWith('.vercel.app')
|
|
614
|
-
|| hostname.endsWith('.vercel.run')
|
|
615
|
-
);
|
|
616
|
-
} catch {
|
|
617
|
-
return false;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
736
|
function selectBuilderElement(blockId: string | undefined): void {
|
|
622
737
|
document
|
|
623
738
|
.querySelectorAll<HTMLElement>(BUILDER_SELECTED_SELECTOR)
|
|
@@ -631,7 +746,10 @@ function selectBuilderElement(blockId: string | undefined): void {
|
|
|
631
746
|
if (!target) return;
|
|
632
747
|
|
|
633
748
|
target.setAttribute('data-builder-selected', 'true');
|
|
634
|
-
target.
|
|
749
|
+
const pageId = target.getAttribute('data-page-id');
|
|
750
|
+
const scrollBlock =
|
|
751
|
+
pageId === 'footer' ? 'end' : pageId === 'navigation' ? 'start' : 'nearest';
|
|
752
|
+
target.scrollIntoView({ behavior: 'smooth', block: scrollBlock });
|
|
635
753
|
}
|
|
636
754
|
|
|
637
755
|
function createBuilderSelection(blockElement: HTMLElement, target: Element): BuilderSelection {
|
|
@@ -825,7 +943,8 @@ function installBuilderPreviewHoverInspector(): () => void {
|
|
|
825
943
|
};
|
|
826
944
|
}
|
|
827
945
|
|
|
828
|
-
const INSERTER_HOVER_BAND_PX =
|
|
946
|
+
const INSERTER_HOVER_BAND_PX = 28;
|
|
947
|
+
const INSERTER_CLEAR_DELAY_MS = 150;
|
|
829
948
|
|
|
830
949
|
/**
|
|
831
950
|
* Direct-manipulation bridge: tracks the bounding rect of the selected
|
|
@@ -924,30 +1043,49 @@ function installBuilderDirectManipulation(
|
|
|
924
1043
|
// a tracked block stack. We don't try to be clever about which gap;
|
|
925
1044
|
// every direct ancestor with a list of `[data-builder-block]` children
|
|
926
1045
|
// contributes one gap per pair.
|
|
1046
|
+
let inserterClearTimer: number | null = null;
|
|
1047
|
+
|
|
927
1048
|
const postInserter = (
|
|
928
1049
|
index: number | null,
|
|
929
1050
|
rect: DOMRect | null,
|
|
930
1051
|
) => {
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
rect
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1052
|
+
const emitInserter = () => {
|
|
1053
|
+
const key =
|
|
1054
|
+
index === null || !rect
|
|
1055
|
+
? '__none__'
|
|
1056
|
+
: `${index}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
|
|
1057
|
+
if (key === lastInserterKey) return;
|
|
1058
|
+
lastInserterKey = key;
|
|
1059
|
+
postMessage({
|
|
1060
|
+
type: 'INSERTER_HOVER',
|
|
1061
|
+
revision: getRevision(),
|
|
1062
|
+
index,
|
|
1063
|
+
rect:
|
|
1064
|
+
rect === null
|
|
1065
|
+
? null
|
|
1066
|
+
: {
|
|
1067
|
+
top: rect.top,
|
|
1068
|
+
left: rect.left,
|
|
1069
|
+
width: rect.width,
|
|
1070
|
+
height: rect.height,
|
|
1071
|
+
},
|
|
1072
|
+
});
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
if (index !== null && rect !== null) {
|
|
1076
|
+
if (inserterClearTimer !== null) {
|
|
1077
|
+
window.clearTimeout(inserterClearTimer);
|
|
1078
|
+
inserterClearTimer = null;
|
|
1079
|
+
}
|
|
1080
|
+
emitInserter();
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (inserterClearTimer !== null) return;
|
|
1085
|
+
inserterClearTimer = window.setTimeout(() => {
|
|
1086
|
+
inserterClearTimer = null;
|
|
1087
|
+
emitInserter();
|
|
1088
|
+
}, INSERTER_CLEAR_DELAY_MS);
|
|
951
1089
|
};
|
|
952
1090
|
|
|
953
1091
|
const handleMouseMove = (event: MouseEvent) => {
|
|
@@ -1103,3 +1241,34 @@ function installBuilderDirectManipulation(
|
|
|
1103
1241
|
window.removeEventListener('mousedown', handleMouseDown, true);
|
|
1104
1242
|
};
|
|
1105
1243
|
}
|
|
1244
|
+
|
|
1245
|
+
export {
|
|
1246
|
+
getBuilderBlockSettingText,
|
|
1247
|
+
getBuilderProductBlockAttributes,
|
|
1248
|
+
getLayoutPageBlockAttributes,
|
|
1249
|
+
getProductBlockText,
|
|
1250
|
+
getProductPageBlockAttributes,
|
|
1251
|
+
} from './product-page.js';
|
|
1252
|
+
export {
|
|
1253
|
+
createStandardProductBlockRegistry,
|
|
1254
|
+
splitStandardProductPageBlocks,
|
|
1255
|
+
buildStandardProductInfoTabs,
|
|
1256
|
+
resolveStandardBuyBoxLabels,
|
|
1257
|
+
resolveStandardDetailsLabels,
|
|
1258
|
+
resolveStandardRelatedProductsTitle,
|
|
1259
|
+
resolveScopedBlockSettingText,
|
|
1260
|
+
STANDARD_PRODUCT_BLOCK_TYPES,
|
|
1261
|
+
STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES,
|
|
1262
|
+
STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES,
|
|
1263
|
+
} from './standard-product-blocks.js';
|
|
1264
|
+
export type {
|
|
1265
|
+
StandardBuyBoxLabels,
|
|
1266
|
+
StandardDetailsLabels,
|
|
1267
|
+
StandardProductBlockRegistryData,
|
|
1268
|
+
StandardProductBlockRegistryOptions,
|
|
1269
|
+
StandardProductBlockRegistrySlots,
|
|
1270
|
+
StandardProductBlockType,
|
|
1271
|
+
StandardProductSettingScope,
|
|
1272
|
+
StandardProductTabSpec,
|
|
1273
|
+
} from './standard-product-blocks.js';
|
|
1274
|
+
export { getBuilderPreviewReviewFixtures } from './preview-fixtures.js';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
buildSearchShellStyle,
|
|
4
|
+
resolveSearchBarSettings,
|
|
5
|
+
resolveSearchColor,
|
|
6
|
+
resolveSearchMaxWidth,
|
|
7
|
+
} from './search-bar-settings';
|
|
8
|
+
|
|
9
|
+
describe('resolveSearchBarSettings', () => {
|
|
10
|
+
test('hero variant prefers hero settings and falls back to navigation', () => {
|
|
11
|
+
const resolved = resolveSearchBarSettings({
|
|
12
|
+
variant: 'hero',
|
|
13
|
+
headerSettings: {
|
|
14
|
+
'navigation.search.placeholder': 'Header placeholder',
|
|
15
|
+
'navigation.search.background': '#111111',
|
|
16
|
+
},
|
|
17
|
+
heroValues: {
|
|
18
|
+
'hero.search.placeholder': 'Hero placeholder',
|
|
19
|
+
'hero.search.maxWidth': 'lg',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(resolved.placeholder).toBe('Hero placeholder');
|
|
24
|
+
expect(resolved.backgroundColor).toBe('#111111');
|
|
25
|
+
expect(resolved.maxWidth).toBe('36rem');
|
|
26
|
+
expect(resolved.showShortcut).toBe(true);
|
|
27
|
+
expect(resolved.showInHero).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('navigation variant reads header settings only', () => {
|
|
31
|
+
const resolved = resolveSearchBarSettings({
|
|
32
|
+
variant: 'navigation',
|
|
33
|
+
headerSettings: {
|
|
34
|
+
'navigation.search.placeholder': 'Find products',
|
|
35
|
+
'navigation.search.borderRadius': 24,
|
|
36
|
+
},
|
|
37
|
+
heroValues: {
|
|
38
|
+
'hero.search.placeholder': 'Ignored in navigation mode',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(resolved.placeholder).toBe('Find products');
|
|
43
|
+
expect(resolved.borderRadiusPx).toBe(24);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('search shell helpers', () => {
|
|
48
|
+
test('resolveSearchColor normalizes hex values', () => {
|
|
49
|
+
expect(resolveSearchColor('f97316')).toBe('#f97316');
|
|
50
|
+
expect(resolveSearchColor('not-a-color')).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('resolveSearchMaxWidth maps preset keys', () => {
|
|
54
|
+
expect(resolveSearchMaxWidth('full')).toBe('100%');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('buildSearchShellStyle returns only overridden properties', () => {
|
|
58
|
+
expect(
|
|
59
|
+
buildSearchShellStyle({
|
|
60
|
+
backgroundColor: '#101010',
|
|
61
|
+
borderColor: '#333333',
|
|
62
|
+
borderRadiusPx: 999,
|
|
63
|
+
}),
|
|
64
|
+
).toEqual({
|
|
65
|
+
backgroundColor: '#101010',
|
|
66
|
+
borderColor: '#333333',
|
|
67
|
+
borderWidth: '1px',
|
|
68
|
+
borderStyle: 'solid',
|
|
69
|
+
borderRadius: '999px',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { BuilderSettings } from '@shoppex/builder-contracts';
|
|
2
|
+
|
|
3
|
+
export const SEARCH_BAR_MAX_WIDTH: Record<string, string> = {
|
|
4
|
+
sm: '20rem',
|
|
5
|
+
md: '28rem',
|
|
6
|
+
lg: '36rem',
|
|
7
|
+
full: '100%',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ResolvedSearchBarSettings = {
|
|
11
|
+
placeholder: string;
|
|
12
|
+
backgroundColor?: string;
|
|
13
|
+
borderColor?: string;
|
|
14
|
+
borderRadiusPx?: number;
|
|
15
|
+
maxWidth: string;
|
|
16
|
+
showShortcut: boolean;
|
|
17
|
+
showInHero: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function getNavigationHeaderSettings(
|
|
21
|
+
settings: BuilderSettings,
|
|
22
|
+
): Record<string, unknown> {
|
|
23
|
+
const block = settings.theme.layout.navigation?.blocks.find(
|
|
24
|
+
(candidate) => candidate.type === 'header',
|
|
25
|
+
);
|
|
26
|
+
return block?.settings ?? {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readSetting(
|
|
30
|
+
sources: Array<Record<string, unknown> | undefined>,
|
|
31
|
+
path: string,
|
|
32
|
+
): unknown {
|
|
33
|
+
for (const source of sources) {
|
|
34
|
+
if (!source) continue;
|
|
35
|
+
if (!Object.prototype.hasOwnProperty.call(source, path)) continue;
|
|
36
|
+
const value = source[path];
|
|
37
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isValidHexColor(value: unknown): value is string {
|
|
45
|
+
if (typeof value !== 'string' || !value.trim()) return false;
|
|
46
|
+
return /^#?[a-f\d]{3}(?:[a-f\d]{3})?(?:[a-f\d]{2})?$/i.test(value.trim());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeHexColor(value: string): string {
|
|
50
|
+
const trimmed = value.trim();
|
|
51
|
+
return trimmed.startsWith('#') ? trimmed : `#${trimmed}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveSearchColor(value: unknown): string | undefined {
|
|
55
|
+
if (!isValidHexColor(value)) return undefined;
|
|
56
|
+
return normalizeHexColor(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveSearchBorderRadius(value: unknown): number | undefined {
|
|
60
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
|
61
|
+
return Math.max(0, Math.round(value));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveSearchMaxWidth(value: unknown, fallback = 'md'): string {
|
|
65
|
+
if (typeof value === 'string' && value in SEARCH_BAR_MAX_WIDTH) {
|
|
66
|
+
return SEARCH_BAR_MAX_WIDTH[value]!;
|
|
67
|
+
}
|
|
68
|
+
return SEARCH_BAR_MAX_WIDTH[fallback] ?? SEARCH_BAR_MAX_WIDTH.md!;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveSearchBoolean(value: unknown, fallback: boolean): boolean {
|
|
72
|
+
if (typeof value === 'boolean') return value;
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveSearchPlaceholder(
|
|
77
|
+
value: unknown,
|
|
78
|
+
fallback: string,
|
|
79
|
+
): string {
|
|
80
|
+
return typeof value === 'string' && value.trim() ? value : fallback;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolveSearchBarSettings(input: {
|
|
84
|
+
variant: 'hero' | 'navigation';
|
|
85
|
+
heroValues: Record<string, unknown>;
|
|
86
|
+
headerSettings: Record<string, unknown>;
|
|
87
|
+
}): ResolvedSearchBarSettings {
|
|
88
|
+
const navSources = [input.headerSettings];
|
|
89
|
+
const heroSources = [input.heroValues, input.headerSettings];
|
|
90
|
+
|
|
91
|
+
if (input.variant === 'hero') {
|
|
92
|
+
return {
|
|
93
|
+
placeholder: resolveSearchPlaceholder(
|
|
94
|
+
readSetting(heroSources, 'hero.search.placeholder') ??
|
|
95
|
+
readSetting(navSources, 'navigation.search.placeholder'),
|
|
96
|
+
'Search products…',
|
|
97
|
+
),
|
|
98
|
+
backgroundColor: resolveSearchColor(
|
|
99
|
+
readSetting(heroSources, 'hero.search.background') ??
|
|
100
|
+
readSetting(navSources, 'navigation.search.background'),
|
|
101
|
+
),
|
|
102
|
+
borderColor: resolveSearchColor(
|
|
103
|
+
readSetting(heroSources, 'hero.search.borderColor') ??
|
|
104
|
+
readSetting(navSources, 'navigation.search.borderColor'),
|
|
105
|
+
),
|
|
106
|
+
borderRadiusPx: resolveSearchBorderRadius(
|
|
107
|
+
readSetting(heroSources, 'hero.search.borderRadius') ??
|
|
108
|
+
readSetting(navSources, 'navigation.search.borderRadius'),
|
|
109
|
+
),
|
|
110
|
+
maxWidth: resolveSearchMaxWidth(
|
|
111
|
+
readSetting(heroSources, 'hero.search.maxWidth'),
|
|
112
|
+
'md',
|
|
113
|
+
),
|
|
114
|
+
showShortcut: resolveSearchBoolean(
|
|
115
|
+
readSetting(heroSources, 'hero.search.showShortcut'),
|
|
116
|
+
true,
|
|
117
|
+
),
|
|
118
|
+
showInHero: resolveSearchBoolean(
|
|
119
|
+
readSetting(heroSources, 'hero.search.show'),
|
|
120
|
+
true,
|
|
121
|
+
),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
placeholder: resolveSearchPlaceholder(
|
|
127
|
+
readSetting(navSources, 'navigation.search.placeholder'),
|
|
128
|
+
'Search products…',
|
|
129
|
+
),
|
|
130
|
+
backgroundColor: resolveSearchColor(
|
|
131
|
+
readSetting(navSources, 'navigation.search.background'),
|
|
132
|
+
),
|
|
133
|
+
borderColor: resolveSearchColor(
|
|
134
|
+
readSetting(navSources, 'navigation.search.borderColor'),
|
|
135
|
+
),
|
|
136
|
+
borderRadiusPx: resolveSearchBorderRadius(
|
|
137
|
+
readSetting(navSources, 'navigation.search.borderRadius'),
|
|
138
|
+
),
|
|
139
|
+
maxWidth: SEARCH_BAR_MAX_WIDTH.md!,
|
|
140
|
+
showShortcut: true,
|
|
141
|
+
showInHero: true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildSearchShellStyle(
|
|
146
|
+
settings: Pick<
|
|
147
|
+
ResolvedSearchBarSettings,
|
|
148
|
+
'backgroundColor' | 'borderColor' | 'borderRadiusPx'
|
|
149
|
+
>,
|
|
150
|
+
): {
|
|
151
|
+
backgroundColor?: string;
|
|
152
|
+
borderColor?: string;
|
|
153
|
+
borderWidth?: string;
|
|
154
|
+
borderStyle?: 'solid';
|
|
155
|
+
borderRadius?: string;
|
|
156
|
+
} | undefined {
|
|
157
|
+
const style: {
|
|
158
|
+
backgroundColor?: string;
|
|
159
|
+
borderColor?: string;
|
|
160
|
+
borderWidth?: string;
|
|
161
|
+
borderStyle?: 'solid';
|
|
162
|
+
borderRadius?: string;
|
|
163
|
+
} = {};
|
|
164
|
+
if (settings.backgroundColor) {
|
|
165
|
+
style.backgroundColor = settings.backgroundColor;
|
|
166
|
+
}
|
|
167
|
+
if (settings.borderColor) {
|
|
168
|
+
style.borderColor = settings.borderColor;
|
|
169
|
+
style.borderWidth = '1px';
|
|
170
|
+
style.borderStyle = 'solid';
|
|
171
|
+
}
|
|
172
|
+
if (settings.borderRadiusPx !== undefined) {
|
|
173
|
+
style.borderRadius = `${settings.borderRadiusPx}px`;
|
|
174
|
+
}
|
|
175
|
+
return Object.keys(style).length > 0 ? style : undefined;
|
|
176
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createBlockInstance } from '@shoppex/builder-contracts';
|
|
3
|
+
import { createElement } from 'react';
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
5
|
+
import { createStandardProductBlockRegistry } from './standard-product-blocks.js';
|
|
6
|
+
|
|
7
|
+
describe('createStandardProductBlockRegistry', () => {
|
|
8
|
+
test('registers all standard product block types and resolves labels through slots', () => {
|
|
9
|
+
const registry = createStandardProductBlockRegistry({
|
|
10
|
+
data: {
|
|
11
|
+
filteredDescription: 'Description body',
|
|
12
|
+
faqCount: 1,
|
|
13
|
+
reviewCount: 2,
|
|
14
|
+
relatedProductsCount: 1,
|
|
15
|
+
},
|
|
16
|
+
slots: {
|
|
17
|
+
renderGallery: ({ attrs }) => createElement('div', { ...attrs, 'data-slot': 'gallery' }),
|
|
18
|
+
renderBuyBox: ({ attrs, labels }) =>
|
|
19
|
+
createElement('div', {
|
|
20
|
+
...attrs,
|
|
21
|
+
'data-slot': 'buy-box',
|
|
22
|
+
'data-variant-label': labels.variantLabel ?? '',
|
|
23
|
+
}),
|
|
24
|
+
renderDetails: ({ attrs, tabs }) =>
|
|
25
|
+
createElement('div', {
|
|
26
|
+
...attrs,
|
|
27
|
+
'data-slot': 'details',
|
|
28
|
+
'data-tab-count': String(tabs.length),
|
|
29
|
+
}),
|
|
30
|
+
renderRelatedProducts: ({ attrs, title }) =>
|
|
31
|
+
createElement('div', {
|
|
32
|
+
...attrs,
|
|
33
|
+
'data-slot': 'related-products',
|
|
34
|
+
'data-title': title ?? '',
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(Object.keys(registry).sort()).toEqual([
|
|
40
|
+
'buy-box',
|
|
41
|
+
'details',
|
|
42
|
+
'gallery',
|
|
43
|
+
'related-products',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const buyBoxBlock = createBlockInstance({
|
|
47
|
+
id: 'buy-box-1',
|
|
48
|
+
type: 'buy-box',
|
|
49
|
+
settings: { variantLabel: 'Variant' },
|
|
50
|
+
});
|
|
51
|
+
const detailsBlock = createBlockInstance({ id: 'details-1', type: 'details' });
|
|
52
|
+
const relatedBlock = createBlockInstance({
|
|
53
|
+
id: 'related-1',
|
|
54
|
+
type: 'related-products',
|
|
55
|
+
settings: { title: 'More products' },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const buyBoxMarkup = renderToStaticMarkup(
|
|
59
|
+
createElement(registry['buy-box'], { block: buyBoxBlock, context: null }),
|
|
60
|
+
);
|
|
61
|
+
const detailsMarkup = renderToStaticMarkup(
|
|
62
|
+
createElement(registry.details, { block: detailsBlock, context: null }),
|
|
63
|
+
);
|
|
64
|
+
const relatedMarkup = renderToStaticMarkup(
|
|
65
|
+
createElement(registry['related-products'], { block: relatedBlock, context: null }),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(buyBoxMarkup).toContain('data-variant-label="Variant"');
|
|
69
|
+
expect(detailsMarkup).toContain('data-tab-count="3"');
|
|
70
|
+
expect(relatedMarkup).toContain('data-title="More products"');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('skips related-products output when there are no related products', () => {
|
|
74
|
+
const registry = createStandardProductBlockRegistry({
|
|
75
|
+
data: {
|
|
76
|
+
filteredDescription: '',
|
|
77
|
+
faqCount: 0,
|
|
78
|
+
reviewCount: 0,
|
|
79
|
+
relatedProductsCount: 0,
|
|
80
|
+
},
|
|
81
|
+
slots: {
|
|
82
|
+
renderGallery: () => null,
|
|
83
|
+
renderBuyBox: () => null,
|
|
84
|
+
renderDetails: () => null,
|
|
85
|
+
renderRelatedProducts: () => createElement('div', { 'data-slot': 'related-products' }),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const relatedBlock = createBlockInstance({ id: 'related-1', type: 'related-products' });
|
|
90
|
+
const output = registry['related-products']({ block: relatedBlock, context: null });
|
|
91
|
+
expect(output).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|