@shoppexio/builder-runtime 0.1.1 → 0.1.3

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 (90) hide show
  1. package/dist/YouTubeEmbed.d.ts +13 -0
  2. package/dist/YouTubeEmbed.d.ts.map +1 -0
  3. package/dist/YouTubeEmbed.js +49 -0
  4. package/dist/YouTubeEmbedBuilderBlock.d.ts +7 -0
  5. package/dist/YouTubeEmbedBuilderBlock.d.ts.map +1 -0
  6. package/dist/YouTubeEmbedBuilderBlock.js +16 -0
  7. package/dist/block-style-settings.d.ts +5 -0
  8. package/dist/block-style-settings.d.ts.map +1 -0
  9. package/dist/block-style-settings.js +16 -0
  10. package/dist/builder-runtime.test.d.ts +2 -0
  11. package/dist/builder-runtime.test.d.ts.map +1 -0
  12. package/dist/builder-runtime.test.js +115 -0
  13. package/dist/content.d.ts +6 -0
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +31 -7
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +8 -0
  19. package/dist/layout.d.ts +3 -8
  20. package/dist/layout.d.ts.map +1 -1
  21. package/dist/layout.js +2 -10
  22. package/dist/manifest-setting-paths.d.ts +5 -0
  23. package/dist/manifest-setting-paths.d.ts.map +1 -0
  24. package/dist/manifest-setting-paths.js +40 -0
  25. package/dist/merchant-custom-page.d.ts +57 -0
  26. package/dist/merchant-custom-page.d.ts.map +1 -0
  27. package/dist/merchant-custom-page.js +63 -0
  28. package/dist/preview-fixtures.d.ts +16 -0
  29. package/dist/preview-fixtures.d.ts.map +1 -0
  30. package/dist/preview-fixtures.js +40 -0
  31. package/dist/preview-mode.d.ts +2 -0
  32. package/dist/preview-mode.d.ts.map +1 -0
  33. package/dist/preview-mode.js +7 -0
  34. package/dist/product-page.d.ts +13 -0
  35. package/dist/product-page.d.ts.map +1 -0
  36. package/dist/product-page.js +18 -0
  37. package/dist/react-runtime.test.d.ts +2 -0
  38. package/dist/react-runtime.test.d.ts.map +1 -0
  39. package/dist/react-runtime.test.js +332 -0
  40. package/dist/react.d.ts +37 -2
  41. package/dist/react.d.ts.map +1 -1
  42. package/dist/react.js +138 -46
  43. package/dist/search-bar-settings.d.ts +33 -0
  44. package/dist/search-bar-settings.d.ts.map +1 -0
  45. package/dist/search-bar-settings.js +99 -0
  46. package/dist/standard-product-blocks.d.ts +48 -0
  47. package/dist/standard-product-blocks.d.ts.map +1 -0
  48. package/dist/standard-product-blocks.js +45 -0
  49. package/dist/standard-product-page.d.ts +69 -0
  50. package/dist/standard-product-page.d.ts.map +1 -0
  51. package/dist/standard-product-page.js +89 -0
  52. package/dist/storefront-google-fonts.d.ts +2 -0
  53. package/dist/storefront-google-fonts.d.ts.map +1 -0
  54. package/dist/storefront-google-fonts.js +28 -0
  55. package/dist/youtube-embed-block.d.ts +10 -0
  56. package/dist/youtube-embed-block.d.ts.map +1 -0
  57. package/dist/youtube-embed-block.js +19 -0
  58. package/dist/youtube.d.ts +5 -0
  59. package/dist/youtube.d.ts.map +1 -0
  60. package/dist/youtube.js +52 -0
  61. package/package.json +3 -3
  62. package/src/YouTubeEmbed.tsx +105 -0
  63. package/src/YouTubeEmbedBuilderBlock.tsx +49 -0
  64. package/src/block-style-settings.ts +24 -0
  65. package/src/builder-runtime.test.ts +69 -0
  66. package/src/content.ts +44 -9
  67. package/src/index.ts +8 -0
  68. package/src/layout.ts +11 -21
  69. package/src/manifest-setting-paths.test.ts +23 -0
  70. package/src/manifest-setting-paths.ts +55 -0
  71. package/src/merchant-custom-page.tsx +161 -0
  72. package/src/preview-fixtures.ts +56 -0
  73. package/src/preview-mode.ts +8 -0
  74. package/src/product-page.test.ts +37 -0
  75. package/src/product-page.ts +32 -0
  76. package/src/react-runtime.test.tsx +42 -0
  77. package/src/react.tsx +243 -49
  78. package/src/search-bar-settings.test.ts +72 -0
  79. package/src/search-bar-settings.ts +176 -0
  80. package/src/standard-product-blocks.test.tsx +93 -0
  81. package/src/standard-product-blocks.tsx +121 -0
  82. package/src/standard-product-page.test.ts +171 -0
  83. package/src/standard-product-page.ts +169 -0
  84. package/src/storefront-google-fonts.test.ts +31 -0
  85. package/src/storefront-google-fonts.ts +43 -0
  86. package/src/youtube-embed-block.test.ts +76 -0
  87. package/src/youtube-embed-block.ts +28 -0
  88. package/src/youtube-embed-builder-block.test.tsx +166 -0
  89. package/src/youtube.test.ts +48 -0
  90. package/src/youtube.ts +66 -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,15 @@ import {
24
27
  getBuilderContentString,
25
28
  getBuilderContentValue,
26
29
  } from './content.js';
30
+ import { resolveManifestSettingRecordValue } from './manifest-setting-paths.js';
31
+ import { BUILDER_PREVIEW_REVIEWS } from './preview-fixtures.js';
27
32
  import { createBuilderCss } from './css-vars.js';
33
+ import { builderBlock, type BuilderAttributeMap } from './attributes.js';
28
34
  import { getPageBlocks, getPageLayout, getVisiblePageBlocks } from './layout.js';
35
+ import {
36
+ getNavigationHeaderSettings,
37
+ resolveSearchBarSettings,
38
+ } from './search-bar-settings.js';
29
39
  import { resolveStyleSlotValue } from './style-slots.js';
30
40
 
31
41
  type BuilderRuntimeContextValue = {
@@ -214,6 +224,7 @@ export function BuilderRuntimePreviewProvider({
214
224
  message: normalized.message,
215
225
  ...(normalized.stack ? { stack: normalized.stack } : {}),
216
226
  source,
227
+ phase: 'runtime',
217
228
  diagnostics: {
218
229
  name: normalized.name,
219
230
  href: window.location.href,
@@ -435,15 +446,86 @@ export function useVisibleBuilderPageBlocks(pageId: string) {
435
446
 
436
447
  export function useThemePageBlocks(pageId: string, defaultOrder: string[]): BlockInstance[] {
437
448
  const { settings } = useBuilderRuntime();
449
+ const canonicalPageId = normalizeDedicatedPageId(pageId);
438
450
 
439
451
  return useMemo(() => {
440
- const page = settings.theme.layout[pageId];
452
+ const page = settings.theme.layout[canonicalPageId];
441
453
  if (page) {
442
454
  return page.blocks.filter((block) => block.visible);
443
455
  }
444
456
 
445
457
  return defaultOrder.map((type) => ({ id: type, type, visible: true, settings: {} }));
446
- }, [defaultOrder, pageId, settings.theme.layout]);
458
+ }, [canonicalPageId, defaultOrder, settings.theme.layout]);
459
+ }
460
+
461
+ export function useThemePageBlockAttributes(
462
+ pageId: string,
463
+ defaultOrder: string[],
464
+ ): BuilderAttributeMap {
465
+ const canonicalPageId = normalizeDedicatedPageId(pageId);
466
+ const blocks = useThemePageBlocks(pageId, defaultOrder);
467
+ const block = blocks[0];
468
+
469
+ return useMemo(
470
+ () => ({
471
+ 'data-page-id': canonicalPageId,
472
+ ...(block ? builderBlock(block.id, block.type) : {}),
473
+ }),
474
+ [block, canonicalPageId],
475
+ );
476
+ }
477
+
478
+ type DedicatedBuilderPageProps<T extends ElementType> = {
479
+ pageId: string;
480
+ defaultBlockOrder: string[];
481
+ as?: T;
482
+ children: ReactNode;
483
+ } & Omit<ComponentPropsWithoutRef<T>, 'as' | 'children'>;
484
+
485
+ export function DedicatedBuilderPage<T extends ElementType = 'section'>({
486
+ pageId,
487
+ defaultBlockOrder,
488
+ as,
489
+ children,
490
+ ...rest
491
+ }: DedicatedBuilderPageProps<T>) {
492
+ const attrs = useThemePageBlockAttributes(pageId, defaultBlockOrder);
493
+ const Component = (as ?? 'section') as ElementType;
494
+
495
+ return createElement(Component, { ...attrs, ...rest }, children);
496
+ }
497
+
498
+ export function useBuilderPreviewReviews<T>(input: {
499
+ reviews: T[];
500
+ isLoading: boolean;
501
+ error: unknown;
502
+ fixtures?: T[];
503
+ }): {
504
+ reviews: T[];
505
+ isLoading: boolean;
506
+ error: unknown;
507
+ isUsingFixtures: boolean;
508
+ } {
509
+ const fixtures = input.fixtures ?? (BUILDER_PREVIEW_REVIEWS as T[]);
510
+ const shouldUseFixtures = isBuilderPreviewRuntime() && Boolean(input.error);
511
+
512
+ return useMemo(() => {
513
+ if (!shouldUseFixtures) {
514
+ return {
515
+ reviews: input.reviews,
516
+ isLoading: input.isLoading,
517
+ error: input.error,
518
+ isUsingFixtures: false,
519
+ };
520
+ }
521
+
522
+ return {
523
+ reviews: fixtures,
524
+ isLoading: false,
525
+ error: null,
526
+ isUsingFixtures: true,
527
+ };
528
+ }, [fixtures, input.error, input.isLoading, input.reviews, shouldUseFixtures]);
447
529
  }
448
530
 
449
531
  export function useBuilderStyleSlot(
@@ -461,13 +543,17 @@ function useScopedBuilderContentValue(path: string): unknown {
461
543
  const block = useContext(BuilderBlockContext);
462
544
  if (!block) return undefined;
463
545
 
464
- if (Object.prototype.hasOwnProperty.call(block.settings, path)) {
465
- return block.settings[path];
546
+ const resolved = resolveManifestSettingRecordValue(block.settings, path);
547
+ if (resolved !== undefined) {
548
+ return resolved;
466
549
  }
467
550
 
468
551
  const shortPath = path.startsWith(`${block.type}.`) ? path.slice(block.type.length + 1) : path.split('.').at(-1);
469
- if (shortPath && Object.prototype.hasOwnProperty.call(block.settings, shortPath)) {
470
- return block.settings[shortPath];
552
+ if (shortPath) {
553
+ const shortResolved = resolveManifestSettingRecordValue(block.settings, shortPath);
554
+ if (shortResolved !== undefined) {
555
+ return shortResolved;
556
+ }
471
557
  }
472
558
 
473
559
  const nested = shortPath ? getNestedBuilderSetting(block.settings, shortPath) : undefined;
@@ -485,7 +571,60 @@ function getNestedBuilderSetting(record: Record<string, unknown>, path: string):
485
571
  return current;
486
572
  }
487
573
 
488
- function isBuilderPreviewRuntime(): boolean {
574
+ export function useSearchBarSettings(input: {
575
+ variant: 'hero' | 'navigation';
576
+ headerSettings?: Record<string, unknown>;
577
+ }) {
578
+ const runtime = useBuilderRuntime();
579
+ const headerSettings = useMemo(
580
+ () => input.headerSettings ?? getNavigationHeaderSettings(runtime.settings),
581
+ [input.headerSettings, runtime.settings],
582
+ );
583
+
584
+ const heroPlaceholder = useBuilderContentValue('hero.search.placeholder');
585
+ const heroBackground = useBuilderContentValue('hero.search.background');
586
+ const heroBorderColor = useBuilderContentValue('hero.search.borderColor');
587
+ const heroBorderRadius = useBuilderContentValue('hero.search.borderRadius');
588
+ const heroMaxWidth = useBuilderContentValue('hero.search.maxWidth');
589
+ const heroShowShortcut = useBuilderContentValue('hero.search.showShortcut');
590
+ const heroShow = useBuilderContentValue('hero.search.show');
591
+
592
+ return useMemo(
593
+ () =>
594
+ resolveSearchBarSettings({
595
+ variant: input.variant,
596
+ headerSettings,
597
+ heroValues: {
598
+ 'hero.search.placeholder': heroPlaceholder,
599
+ 'hero.search.background': heroBackground,
600
+ 'hero.search.borderColor': heroBorderColor,
601
+ 'hero.search.borderRadius': heroBorderRadius,
602
+ 'hero.search.maxWidth': heroMaxWidth,
603
+ 'hero.search.showShortcut': heroShowShortcut,
604
+ 'hero.search.show': heroShow,
605
+ },
606
+ }),
607
+ [
608
+ headerSettings,
609
+ heroBackground,
610
+ heroBorderColor,
611
+ heroBorderRadius,
612
+ heroMaxWidth,
613
+ heroPlaceholder,
614
+ heroShow,
615
+ heroShowShortcut,
616
+ input.variant,
617
+ ],
618
+ );
619
+ }
620
+
621
+ export {
622
+ buildSearchShellStyle,
623
+ getNavigationHeaderSettings,
624
+ resolveSearchBarSettings,
625
+ } from './search-bar-settings.js';
626
+
627
+ export function isBuilderPreviewRuntime(): boolean {
489
628
  if (typeof window === 'undefined') {
490
629
  return false;
491
630
  }
@@ -587,7 +726,7 @@ function getPreviewParentOrigin(location: Location, referrer: string): string |
587
726
  function isTrustedBuilderPreviewEmbed(location: Location, parentOrigin: string | null): boolean {
588
727
  if (window.parent === window || !parentOrigin) return false;
589
728
  if (!hasBuilderPreviewMode(location)) return false;
590
- return isTrustedBuilderPreviewParentOrigin(parentOrigin);
729
+ return isTrustedPreviewParentOrigin(parentOrigin);
591
730
  }
592
731
 
593
732
  function hasBuilderPreviewMode(location: Location): boolean {
@@ -599,25 +738,6 @@ function hasBuilderPreviewMode(location: Location): boolean {
599
738
  );
600
739
  }
601
740
 
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
741
  function selectBuilderElement(blockId: string | undefined): void {
622
742
  document
623
743
  .querySelectorAll<HTMLElement>(BUILDER_SELECTED_SELECTOR)
@@ -631,7 +751,10 @@ function selectBuilderElement(blockId: string | undefined): void {
631
751
  if (!target) return;
632
752
 
633
753
  target.setAttribute('data-builder-selected', 'true');
634
- target.scrollIntoView({ behavior: 'smooth', block: 'center' });
754
+ const pageId = target.getAttribute('data-page-id');
755
+ const scrollBlock =
756
+ pageId === 'footer' ? 'end' : pageId === 'navigation' ? 'start' : 'nearest';
757
+ target.scrollIntoView({ behavior: 'smooth', block: scrollBlock });
635
758
  }
636
759
 
637
760
  function createBuilderSelection(blockElement: HTMLElement, target: Element): BuilderSelection {
@@ -785,6 +908,7 @@ function installBuilderPreviewHoverInspector(): () => void {
785
908
  // we keep a small override table for these.
786
909
  const SLUG_LABEL_OVERRIDES: Record<string, string> = {
787
910
  'custom-html': 'Custom Embed',
911
+ 'youtube-embed': 'YouTube Video',
788
912
  };
789
913
  const override = SLUG_LABEL_OVERRIDES[type];
790
914
  const label = override ?? type
@@ -825,7 +949,8 @@ function installBuilderPreviewHoverInspector(): () => void {
825
949
  };
826
950
  }
827
951
 
828
- const INSERTER_HOVER_BAND_PX = 16;
952
+ const INSERTER_HOVER_BAND_PX = 28;
953
+ const INSERTER_CLEAR_DELAY_MS = 150;
829
954
 
830
955
  /**
831
956
  * Direct-manipulation bridge: tracks the bounding rect of the selected
@@ -924,30 +1049,49 @@ function installBuilderDirectManipulation(
924
1049
  // a tracked block stack. We don't try to be clever about which gap;
925
1050
  // every direct ancestor with a list of `[data-builder-block]` children
926
1051
  // contributes one gap per pair.
1052
+ let inserterClearTimer: number | null = null;
1053
+
927
1054
  const postInserter = (
928
1055
  index: number | null,
929
1056
  rect: DOMRect | null,
930
1057
  ) => {
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
- });
1058
+ const emitInserter = () => {
1059
+ const key =
1060
+ index === null || !rect
1061
+ ? '__none__'
1062
+ : `${index}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
1063
+ if (key === lastInserterKey) return;
1064
+ lastInserterKey = key;
1065
+ postMessage({
1066
+ type: 'INSERTER_HOVER',
1067
+ revision: getRevision(),
1068
+ index,
1069
+ rect:
1070
+ rect === null
1071
+ ? null
1072
+ : {
1073
+ top: rect.top,
1074
+ left: rect.left,
1075
+ width: rect.width,
1076
+ height: rect.height,
1077
+ },
1078
+ });
1079
+ };
1080
+
1081
+ if (index !== null && rect !== null) {
1082
+ if (inserterClearTimer !== null) {
1083
+ window.clearTimeout(inserterClearTimer);
1084
+ inserterClearTimer = null;
1085
+ }
1086
+ emitInserter();
1087
+ return;
1088
+ }
1089
+
1090
+ if (inserterClearTimer !== null) return;
1091
+ inserterClearTimer = window.setTimeout(() => {
1092
+ inserterClearTimer = null;
1093
+ emitInserter();
1094
+ }, INSERTER_CLEAR_DELAY_MS);
951
1095
  };
952
1096
 
953
1097
  const handleMouseMove = (event: MouseEvent) => {
@@ -1103,3 +1247,53 @@ function installBuilderDirectManipulation(
1103
1247
  window.removeEventListener('mousedown', handleMouseDown, true);
1104
1248
  };
1105
1249
  }
1250
+
1251
+ export {
1252
+ getBuilderBlockSettingText,
1253
+ getBuilderProductBlockAttributes,
1254
+ getLayoutPageBlockAttributes,
1255
+ getProductBlockText,
1256
+ getProductPageBlockAttributes,
1257
+ } from './product-page.js';
1258
+ export {
1259
+ createStandardProductBlockRegistry,
1260
+ splitStandardProductPageBlocks,
1261
+ buildStandardProductInfoTabs,
1262
+ resolveStandardBuyBoxLabels,
1263
+ resolveStandardDetailsLabels,
1264
+ resolveStandardRelatedProductsTitle,
1265
+ resolveScopedBlockSettingText,
1266
+ STANDARD_PRODUCT_BLOCK_TYPES,
1267
+ STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES,
1268
+ STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES,
1269
+ } from './standard-product-blocks.js';
1270
+ export type {
1271
+ StandardBuyBoxLabels,
1272
+ StandardDetailsLabels,
1273
+ StandardProductBlockRegistryData,
1274
+ StandardProductBlockRegistryOptions,
1275
+ StandardProductBlockRegistrySlots,
1276
+ StandardProductBlockType,
1277
+ StandardProductSettingScope,
1278
+ StandardProductTabSpec,
1279
+ } from './standard-product-blocks.js';
1280
+ export { YouTubeEmbed, YouTubeEmbedPreviewPlaceholder, type YouTubeEmbedProps } from './YouTubeEmbed.js';
1281
+ export {
1282
+ YouTubeEmbedBuilderBlock,
1283
+ type YouTubeEmbedBuilderBlockProps,
1284
+ } from './YouTubeEmbedBuilderBlock.js';
1285
+ export {
1286
+ createMerchantCustomPageRegistry,
1287
+ MerchantCustomPageBuilderView,
1288
+ useMerchantCustomPageRegistry,
1289
+ useMerchantCustomPageView,
1290
+ type MerchantCustomPageRegistryOptions,
1291
+ } from './merchant-custom-page.js';
1292
+ export { isBuilderPreviewMode } from './preview-mode.js';
1293
+ export {
1294
+ getYouTubeEmbedBlockStyleProps,
1295
+ readYouTubeEmbedBlockSettings,
1296
+ type YouTubeEmbedBlockInstance,
1297
+ } from './youtube-embed-block.js';
1298
+ export { readManifestStyleBlockProps } from './block-style-settings.js';
1299
+ 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
+ }