@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.
Files changed (44) hide show
  1. package/dist/index.d.ts +4 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -0
  4. package/dist/layout.d.ts +3 -8
  5. package/dist/layout.d.ts.map +1 -1
  6. package/dist/layout.js +2 -10
  7. package/dist/preview-fixtures.d.ts +16 -0
  8. package/dist/preview-fixtures.d.ts.map +1 -0
  9. package/dist/preview-fixtures.js +40 -0
  10. package/dist/product-page.d.ts +13 -0
  11. package/dist/product-page.d.ts.map +1 -0
  12. package/dist/product-page.js +18 -0
  13. package/dist/react.d.ts +31 -2
  14. package/dist/react.d.ts.map +1 -1
  15. package/dist/react.js +122 -42
  16. package/dist/search-bar-settings.d.ts +33 -0
  17. package/dist/search-bar-settings.d.ts.map +1 -0
  18. package/dist/search-bar-settings.js +99 -0
  19. package/dist/standard-product-blocks.d.ts +48 -0
  20. package/dist/standard-product-blocks.d.ts.map +1 -0
  21. package/dist/standard-product-blocks.js +45 -0
  22. package/dist/standard-product-page.d.ts +69 -0
  23. package/dist/standard-product-page.d.ts.map +1 -0
  24. package/dist/standard-product-page.js +89 -0
  25. package/dist/storefront-google-fonts.d.ts +2 -0
  26. package/dist/storefront-google-fonts.d.ts.map +1 -0
  27. package/dist/storefront-google-fonts.js +28 -0
  28. package/package.json +3 -3
  29. package/src/builder-runtime.test.ts +33 -0
  30. package/src/index.ts +4 -0
  31. package/src/layout.ts +11 -21
  32. package/src/preview-fixtures.ts +56 -0
  33. package/src/product-page.test.ts +37 -0
  34. package/src/product-page.ts +32 -0
  35. package/src/react-runtime.test.tsx +42 -0
  36. package/src/react.tsx +214 -45
  37. package/src/search-bar-settings.test.ts +72 -0
  38. package/src/search-bar-settings.ts +176 -0
  39. package/src/standard-product-blocks.test.tsx +93 -0
  40. package/src/standard-product-blocks.tsx +121 -0
  41. package/src/standard-product-page.test.ts +171 -0
  42. package/src/standard-product-page.ts +169 -0
  43. package/src/storefront-google-fonts.test.ts +31 -0
  44. 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[pageId];
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
- }, [defaultOrder, pageId, settings.theme.layout]);
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 isBuilderPreviewRuntime(): boolean {
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 isTrustedBuilderPreviewParentOrigin(parentOrigin);
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.scrollIntoView({ behavior: 'smooth', block: 'center' });
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 = 16;
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 key =
932
- index === null || !rect
933
- ? '__none__'
934
- : `${index}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
935
- if (key === lastInserterKey) return;
936
- lastInserterKey = key;
937
- postMessage({
938
- type: 'INSERTER_HOVER',
939
- revision: getRevision(),
940
- index,
941
- rect:
942
- rect === null
943
- ? null
944
- : {
945
- top: rect.top,
946
- left: rect.left,
947
- width: rect.width,
948
- height: rect.height,
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
+ });