@shoppexio/builder-runtime 0.1.0 → 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 (53) hide show
  1. package/dist/css-vars.d.ts.map +1 -1
  2. package/dist/css-vars.js +24 -6
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -0
  6. package/dist/layout.d.ts +5 -1
  7. package/dist/layout.d.ts.map +1 -1
  8. package/dist/layout.js +2 -0
  9. package/dist/preview-fixtures.d.ts +16 -0
  10. package/dist/preview-fixtures.d.ts.map +1 -0
  11. package/dist/preview-fixtures.js +40 -0
  12. package/dist/product-page.d.ts +13 -0
  13. package/dist/product-page.d.ts.map +1 -0
  14. package/dist/product-page.js +18 -0
  15. package/dist/react.d.ts +36 -224
  16. package/dist/react.d.ts.map +1 -1
  17. package/dist/react.js +606 -47
  18. package/dist/search-bar-settings.d.ts +33 -0
  19. package/dist/search-bar-settings.d.ts.map +1 -0
  20. package/dist/search-bar-settings.js +99 -0
  21. package/dist/standard-product-blocks.d.ts +48 -0
  22. package/dist/standard-product-blocks.d.ts.map +1 -0
  23. package/dist/standard-product-blocks.js +45 -0
  24. package/dist/standard-product-page.d.ts +69 -0
  25. package/dist/standard-product-page.d.ts.map +1 -0
  26. package/dist/standard-product-page.js +89 -0
  27. package/dist/storefront-google-fonts.d.ts +2 -0
  28. package/dist/storefront-google-fonts.d.ts.map +1 -0
  29. package/dist/storefront-google-fonts.js +28 -0
  30. package/package.json +3 -3
  31. package/src/builder-runtime.test.ts +57 -0
  32. package/src/css-vars.ts +29 -8
  33. package/src/index.ts +4 -0
  34. package/src/layout.ts +14 -1
  35. package/src/preview-fixtures.ts +56 -0
  36. package/src/product-page.test.ts +37 -0
  37. package/src/product-page.ts +32 -0
  38. package/src/react-runtime.test.tsx +215 -3
  39. package/src/react.tsx +769 -45
  40. package/src/search-bar-settings.test.ts +72 -0
  41. package/src/search-bar-settings.ts +176 -0
  42. package/src/standard-product-blocks.test.tsx +93 -0
  43. package/src/standard-product-blocks.tsx +121 -0
  44. package/src/standard-product-page.test.ts +171 -0
  45. package/src/standard-product-page.ts +169 -0
  46. package/src/storefront-google-fonts.test.ts +31 -0
  47. package/src/storefront-google-fonts.ts +43 -0
  48. package/dist/builder-runtime.test.d.ts +0 -2
  49. package/dist/builder-runtime.test.d.ts.map +0 -1
  50. package/dist/builder-runtime.test.js +0 -115
  51. package/dist/react-runtime.test.d.ts +0 -2
  52. package/dist/react-runtime.test.d.ts.map +0 -1
  53. package/dist/react-runtime.test.js +0 -292
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 = {
@@ -37,6 +46,11 @@ const BUILDER_STATE_EVENT_NAME = 'shoppex:builder-state';
37
46
  const BUILDER_BLOCK_SELECTOR = '[data-builder-block]';
38
47
  const BUILDER_CONTENT_SELECTOR = '[data-builder-content]';
39
48
  const BUILDER_SELECTED_SELECTOR = '[data-builder-selected="true"]';
49
+ const BUILDER_READY_HEALTH = {
50
+ reactMounted: true,
51
+ builderRuntimeProvider: true,
52
+ protocolVersion: 2,
53
+ } as const;
40
54
 
41
55
  const BuilderRuntimeContext = createContext<BuilderRuntimeContextValue | null>(null);
42
56
  const BuilderBlockContext = createContext<BlockInstance | null>(null);
@@ -67,11 +81,14 @@ export function BuilderRuntimePreviewProvider({
67
81
  useEffect(() => {
68
82
  const currentWindow = window as Window & {
69
83
  __SHOPPEX_BUILDER_SETTINGS__?: BuilderSettings;
84
+ __SHOPPEX_PREVIEW_SESSION_PATH__?: string;
70
85
  };
71
- const parentOrigin = getPreviewParentOrigin(document.referrer);
86
+ const parentOrigin = getPreviewParentOrigin(window.location, document.referrer);
72
87
  const isTrustedPreviewEmbed = isTrustedBuilderPreviewEmbed(window.location, parentOrigin);
73
88
  const removeInspectorStyles = isTrustedPreviewEmbed ? installBuilderPreviewInspectorStyles() : () => {};
74
89
  const removeHoverInspector = isTrustedPreviewEmbed ? installBuilderPreviewHoverInspector() : () => {};
90
+ let interactionMode: 'edit' | 'preview' = 'edit';
91
+ let removeDirectManipulation: () => void = () => {};
75
92
 
76
93
  const postToParent = (event: MessageEvent<unknown> | null, response: unknown) => {
77
94
  const target = event?.source && 'postMessage' in event.source ? event.source : window.parent;
@@ -82,6 +99,13 @@ export function BuilderRuntimePreviewProvider({
82
99
  targetOrigin || '*',
83
100
  );
84
101
  };
102
+ const postReady = (event: MessageEvent<unknown> | null) => {
103
+ postToParent(event, {
104
+ type: 'READY',
105
+ revision: settingsRevisionRef.current,
106
+ health: BUILDER_READY_HEALTH,
107
+ });
108
+ };
85
109
 
86
110
  const applySettings = (input: unknown): { status: 'applied'; settings: BuilderSettings } | { status: 'invalid' | 'stale' } => {
87
111
  const parsed = BuilderSettingsSchema.safeParse(input);
@@ -110,7 +134,7 @@ export function BuilderRuntimePreviewProvider({
110
134
  const message = parsed.data;
111
135
 
112
136
  if (message.type === 'REQUEST_READY') {
113
- postToParent(event, { type: 'READY', revision: settingsRevisionRef.current });
137
+ postReady(event);
114
138
  return;
115
139
  }
116
140
 
@@ -131,20 +155,49 @@ export function BuilderRuntimePreviewProvider({
131
155
 
132
156
  if (message.type === 'RELOAD') {
133
157
  postToParent(event, { type: 'APPLIED', revision: message.revision });
134
- window.setTimeout(() => window.location.reload(), 0);
158
+ const previewReloadTarget = resolvePreviewReloadTarget(
159
+ window.location,
160
+ currentWindow.__SHOPPEX_PREVIEW_SESSION_PATH__,
161
+ );
162
+ window.setTimeout(() => {
163
+ if (previewReloadTarget) {
164
+ window.location.href = previewReloadTarget;
165
+ return;
166
+ }
167
+ window.location.reload();
168
+ }, 0);
135
169
  return;
136
170
  }
137
171
 
138
172
  if (message.type === 'SELECT_ELEMENT') {
139
173
  selectBuilderElement(message.selection.blockId);
174
+ return;
175
+ }
176
+
177
+ if (message.type === 'SET_INTERACTION_MODE') {
178
+ interactionMode = message.mode;
179
+ document.documentElement.setAttribute(
180
+ 'data-builder-interaction-mode',
181
+ interactionMode,
182
+ );
183
+ return;
140
184
  }
141
185
  };
142
186
 
143
187
  const handleBuilderClick = (event: MouseEvent) => {
144
188
  if (!isTrustedPreviewEmbed) return;
189
+ // In "preview" interaction mode let the storefront react to clicks
190
+ // naturally so the merchant can test buy-now flows or anchor links.
191
+ if (interactionMode === 'preview') return;
145
192
  const target = event.target;
146
193
  if (!(target instanceof Element)) return;
147
194
 
195
+ // Once an element is in inline-edit mode, clicks on it should
196
+ // place the caret instead of re-firing block selection. The
197
+ // mousedown handler in installBuilderDirectManipulation has
198
+ // already validated this is a legitimate edit interaction.
199
+ if (target.closest('[data-builder-inline-edit="true"]')) return;
200
+
148
201
  const blockElement = target.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
149
202
  if (!blockElement) return;
150
203
 
@@ -161,19 +214,59 @@ export function BuilderRuntimePreviewProvider({
161
214
  });
162
215
  };
163
216
 
164
- if (isTrustedPreviewEmbed) {
165
- postToParent(null, { type: 'READY', revision: settingsRevisionRef.current });
166
- }
217
+ const postPreviewError = (source: 'error' | 'unhandledrejection', error: unknown) => {
218
+ if (!isTrustedPreviewEmbed) return;
219
+ const normalized = normalizePreviewRuntimeError(error);
220
+ postToParent(null, {
221
+ type: 'PREVIEW_ERROR',
222
+ revision: settingsRevisionRef.current,
223
+ message: normalized.message,
224
+ ...(normalized.stack ? { stack: normalized.stack } : {}),
225
+ source,
226
+ phase: 'runtime',
227
+ diagnostics: {
228
+ name: normalized.name,
229
+ href: window.location.href,
230
+ referrer: document.referrer || undefined,
231
+ parentOrigin: parentOrigin ?? undefined,
232
+ previewMode: new URLSearchParams(window.location.search).get('shoppex-preview-mode') ?? undefined,
233
+ userAgent: navigator.userAgent,
234
+ },
235
+ });
236
+ };
237
+
238
+ const handleRuntimeError = (event: ErrorEvent) => {
239
+ postPreviewError('error', event.error ?? event.message);
240
+ };
241
+
242
+ const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
243
+ postPreviewError('unhandledrejection', event.reason);
244
+ };
167
245
 
168
246
  window.addEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
169
247
  window.addEventListener('message', handlePreviewMessage);
170
248
  window.addEventListener('click', handleBuilderClick, true);
249
+ window.addEventListener('error', handleRuntimeError);
250
+ window.addEventListener('unhandledrejection', handleUnhandledRejection);
251
+ if (isTrustedPreviewEmbed) {
252
+ (window as Window & {
253
+ __SHOPPEX_PREVIEW_DISABLE_BOOTSTRAP_ERROR_BRIDGE__?: () => void;
254
+ }).__SHOPPEX_PREVIEW_DISABLE_BOOTSTRAP_ERROR_BRIDGE__?.();
255
+ removeDirectManipulation = installBuilderDirectManipulation(
256
+ (message) => postToParent(null, message),
257
+ () => settingsRevisionRef.current,
258
+ );
259
+ postReady(null);
260
+ }
171
261
  return () => {
172
262
  removeInspectorStyles();
173
263
  removeHoverInspector();
264
+ removeDirectManipulation();
174
265
  window.removeEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
175
266
  window.removeEventListener('message', handlePreviewMessage);
176
267
  window.removeEventListener('click', handleBuilderClick, true);
268
+ window.removeEventListener('error', handleRuntimeError);
269
+ window.removeEventListener('unhandledrejection', handleUnhandledRejection);
177
270
  };
178
271
  }, []);
179
272
 
@@ -242,7 +335,11 @@ export function BuilderPage<TContext = unknown>({
242
335
  {pageBlocks.map((block) => {
243
336
  const Component = registry[block.type];
244
337
  if (!Component) {
245
- return fallback;
338
+ return (
339
+ <BuilderBlockProvider key={block.id} block={block}>
340
+ {fallback ?? renderMissingBuilderBlock(pageId, block)}
341
+ </BuilderBlockProvider>
342
+ );
246
343
  }
247
344
 
248
345
  return (
@@ -255,6 +352,33 @@ export function BuilderPage<TContext = unknown>({
255
352
  );
256
353
  }
257
354
 
355
+ function renderMissingBuilderBlock(pageId: string, block: BlockInstance): ReactNode {
356
+ if (!isBuilderPreviewRuntime()) {
357
+ return null;
358
+ }
359
+
360
+ return createElement(
361
+ 'div',
362
+ {
363
+ 'data-page-id': pageId,
364
+ 'data-builder-block': block.id,
365
+ 'data-builder-block-type': block.type,
366
+ 'data-builder-runtime-error': 'missing-block-component',
367
+ style: {
368
+ margin: '12px 0',
369
+ border: '1px solid #dc2626',
370
+ borderRadius: '8px',
371
+ background: '#fef2f2',
372
+ color: '#7f1d1d',
373
+ padding: '12px',
374
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
375
+ fontSize: '13px',
376
+ },
377
+ },
378
+ `Missing Builder component for block "${block.type}".`,
379
+ );
380
+ }
381
+
258
382
  export function useBuilderRuntime(): BuilderRuntimeContextValue {
259
383
  const context = useContext(BuilderRuntimeContext);
260
384
  if (!context) {
@@ -319,6 +443,90 @@ export function useVisibleBuilderPageBlocks(pageId: string) {
319
443
  return getVisiblePageBlocks(useBuilderRuntime().settings, pageId);
320
444
  }
321
445
 
446
+ export function useThemePageBlocks(pageId: string, defaultOrder: string[]): BlockInstance[] {
447
+ const { settings } = useBuilderRuntime();
448
+ const canonicalPageId = normalizeDedicatedPageId(pageId);
449
+
450
+ return useMemo(() => {
451
+ const page = settings.theme.layout[canonicalPageId];
452
+ if (page) {
453
+ return page.blocks.filter((block) => block.visible);
454
+ }
455
+
456
+ return defaultOrder.map((type) => ({ id: type, type, visible: true, settings: {} }));
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]);
528
+ }
529
+
322
530
  export function useBuilderStyleSlot(
323
531
  slotId: StyleSlotId,
324
532
  input: { breakpoint?: Breakpoint; fallback?: unknown } = {},
@@ -358,6 +566,67 @@ function getNestedBuilderSetting(record: Record<string, unknown>, path: string):
358
566
  return current;
359
567
  }
360
568
 
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 {
623
+ if (typeof window === 'undefined') {
624
+ return false;
625
+ }
626
+
627
+ return window.location.search.includes('shoppex-preview-mode=theme');
628
+ }
629
+
361
630
  function parseInitialBuilderSettings(input: unknown): BuilderSettings {
362
631
  const parsed = BuilderSettingsSchema.safeParse(input);
363
632
  if (parsed.success) return parsed.data;
@@ -399,19 +668,60 @@ function isRecord(value: unknown): value is Record<string, unknown> {
399
668
  return typeof value === 'object' && value !== null && !Array.isArray(value);
400
669
  }
401
670
 
402
- function getPreviewParentOrigin(referrer: string): string | null {
403
- if (!referrer) return null;
671
+ export function resolvePreviewReloadTarget(
672
+ location: Pick<Location, 'search' | 'hash'>,
673
+ sessionPath: unknown,
674
+ ): string | null {
675
+ if (typeof sessionPath !== 'string' || !sessionPath.startsWith('/s/')) {
676
+ return null;
677
+ }
678
+
679
+ return `${sessionPath}${location.search}${location.hash}`;
680
+ }
681
+
682
+ function normalizePreviewRuntimeError(error: unknown): { message: string; stack?: string; name?: string } {
683
+ if (error instanceof Error) {
684
+ return {
685
+ message: error.message || error.name || 'Preview runtime error',
686
+ name: error.name,
687
+ ...(typeof error.stack === 'string' && error.stack ? { stack: error.stack } : {}),
688
+ };
689
+ }
690
+
691
+ if (typeof error === 'string') {
692
+ return { message: error || 'Preview runtime error' };
693
+ }
694
+
695
+ if (isRecord(error)) {
696
+ const message = typeof error.message === 'string' && error.message
697
+ ? error.message
698
+ : 'Preview runtime error';
699
+ const stack = typeof error.stack === 'string' && error.stack ? error.stack : undefined;
700
+ const name = typeof error.name === 'string' && error.name ? error.name : undefined;
701
+ return { message, ...(stack ? { stack } : {}), ...(name ? { name } : {}) };
702
+ }
703
+
704
+ return { message: 'Preview runtime error' };
705
+ }
706
+
707
+ function parseOrigin(value: string | null | undefined): string | null {
708
+ if (!value) return null;
404
709
  try {
405
- return new URL(referrer).origin;
710
+ return new URL(value).origin;
406
711
  } catch {
407
712
  return null;
408
713
  }
409
714
  }
410
715
 
716
+ function getPreviewParentOrigin(location: Location, referrer: string): string | null {
717
+ const explicitOrigin = new URLSearchParams(location.search).get('shoppex-preview-parent-origin');
718
+ return parseOrigin(explicitOrigin) ?? parseOrigin(referrer);
719
+ }
720
+
411
721
  function isTrustedBuilderPreviewEmbed(location: Location, parentOrigin: string | null): boolean {
412
722
  if (window.parent === window || !parentOrigin) return false;
413
723
  if (!hasBuilderPreviewMode(location)) return false;
414
- return isTrustedBuilderPreviewParentOrigin(parentOrigin);
724
+ return isTrustedPreviewParentOrigin(parentOrigin);
415
725
  }
416
726
 
417
727
  function hasBuilderPreviewMode(location: Location): boolean {
@@ -423,25 +733,6 @@ function hasBuilderPreviewMode(location: Location): boolean {
423
733
  );
424
734
  }
425
735
 
426
- function isTrustedBuilderPreviewParentOrigin(origin: string): boolean {
427
- try {
428
- const parsed = new URL(origin);
429
- const hostname = parsed.hostname.toLowerCase();
430
- return (
431
- hostname === 'dashboard.shoppex.io'
432
- || hostname === 'dashboard.shoppex.test'
433
- || hostname === 'localhost'
434
- || hostname === '127.0.0.1'
435
- || hostname === '::1'
436
- || hostname.endsWith('.localhost')
437
- || hostname.endsWith('.vercel.app')
438
- || hostname.endsWith('.vercel.run')
439
- );
440
- } catch {
441
- return false;
442
- }
443
- }
444
-
445
736
  function selectBuilderElement(blockId: string | undefined): void {
446
737
  document
447
738
  .querySelectorAll<HTMLElement>(BUILDER_SELECTED_SELECTOR)
@@ -455,7 +746,10 @@ function selectBuilderElement(blockId: string | undefined): void {
455
746
  if (!target) return;
456
747
 
457
748
  target.setAttribute('data-builder-selected', 'true');
458
- 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 });
459
753
  }
460
754
 
461
755
  function createBuilderSelection(blockElement: HTMLElement, target: Element): BuilderSelection {
@@ -502,49 +796,479 @@ function installBuilderPreviewInspectorStyles(): () => void {
502
796
  position: relative;
503
797
  }
504
798
  [data-builder-block][data-builder-selected="true"] {
505
- outline: 2px solid #2563eb;
799
+ outline: 1px solid rgba(124, 58, 237, 0.7);
506
800
  outline-offset: 4px;
507
801
  }
508
802
  [data-builder-block][data-builder-hovered="true"] {
509
- outline: 1px dashed #2563eb;
803
+ outline: 1px dashed rgba(124, 58, 237, 0.5);
510
804
  outline-offset: 4px;
511
805
  cursor: pointer;
512
806
  }
807
+ /* Block-name tooltip in the upper-left corner of the hovered block.
808
+ Driven by a data-builder-block-label attribute the runtime sets
809
+ from manifest.blocks[type].label when available, falling back to
810
+ the block-type slug. */
811
+ [data-builder-block][data-builder-hovered="true"]::before {
812
+ content: attr(data-builder-block-label);
813
+ position: absolute;
814
+ top: -22px;
815
+ left: 0;
816
+ padding: 2px 6px;
817
+ font-size: 11px;
818
+ font-weight: 500;
819
+ line-height: 1.3;
820
+ color: #ffffff;
821
+ background: #7c3aed;
822
+ border-radius: 4px;
823
+ pointer-events: none;
824
+ white-space: nowrap;
825
+ z-index: 999;
826
+ }
827
+ /* Sub-element hover: thinner outline so a hover on a text or image
828
+ inside a block highlights only that target instead of the whole
829
+ block. Suppressed while the parent block is selected so the
830
+ merchant doesn't see double outlines. */
831
+ [data-builder-content][data-builder-content-hovered="true"] {
832
+ outline: 1px dashed rgba(124, 58, 237, 0.5);
833
+ outline-offset: 2px;
834
+ cursor: pointer;
835
+ }
836
+ [data-builder-block][data-builder-selected="true"] [data-builder-content][data-builder-content-hovered="true"] {
837
+ outline: none;
838
+ }
839
+ /* Inline-edit affordance: hover over editable text content reveals a
840
+ text cursor + subtle brand underline so the merchant can see what
841
+ a double-click would target. Images, links, and buttons stay
842
+ outside this rule — those have their own picker affordances. */
843
+ [data-builder-content][data-builder-content-hovered="true"]:not(img):not(a[href]):not(:has(a[href])):not(:has(button)) {
844
+ text-decoration: underline;
845
+ text-decoration-color: rgba(124, 58, 237, 0.55);
846
+ text-decoration-thickness: 2px;
847
+ text-underline-offset: 4px;
848
+ cursor: text;
849
+ }
850
+ /* Active inline-edit state: replaces the dashed outline with a
851
+ brand-tinted ring so the merchant gets unambiguous focus
852
+ feedback while typing. */
853
+ [data-builder-content][data-builder-inline-edit="true"] {
854
+ outline: 2px solid #7c3aed !important;
855
+ outline-offset: 4px !important;
856
+ border-radius: 4px;
857
+ background: rgba(124, 58, 237, 0.06);
858
+ text-decoration: none !important;
859
+ cursor: text !important;
860
+ caret-color: #7c3aed;
861
+ }
862
+ [data-builder-content][data-builder-inline-edit="true"]:focus {
863
+ outline: 2px solid #7c3aed !important;
864
+ outline-offset: 4px !important;
865
+ }
513
866
  `;
514
867
  document.head.appendChild(style);
515
868
  return () => style.remove();
516
869
  }
517
870
 
518
871
  function installBuilderPreviewHoverInspector(): () => void {
519
- let hovered: HTMLElement | null = null;
872
+ let hoveredBlock: HTMLElement | null = null;
873
+ let hoveredContent: HTMLElement | null = null;
874
+
875
+ const clearHoveredBlock = () => {
876
+ hoveredBlock?.removeAttribute('data-builder-hovered');
877
+ hoveredBlock = null;
878
+ };
520
879
 
521
- const clearHovered = () => {
522
- hovered?.removeAttribute('data-builder-hovered');
523
- hovered = null;
880
+ const clearHoveredContent = () => {
881
+ hoveredContent?.removeAttribute('data-builder-content-hovered');
882
+ hoveredContent = null;
524
883
  };
525
884
 
526
885
  const handleMouseOver = (event: MouseEvent) => {
527
886
  const target = event.target;
528
887
  if (!(target instanceof Element)) return;
529
888
  const blockElement = target.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
530
- if (blockElement === hovered) return;
531
- clearHovered();
532
- if (!blockElement) return;
533
- hovered = blockElement;
534
- hovered.setAttribute('data-builder-hovered', 'true');
889
+ const contentElement = target.closest<HTMLElement>(BUILDER_CONTENT_SELECTOR);
890
+
891
+ if (blockElement !== hoveredBlock) {
892
+ clearHoveredBlock();
893
+ if (blockElement) {
894
+ hoveredBlock = blockElement;
895
+ // Drive the ::before tooltip via a label attribute. Derive
896
+ // it from the block-type slug — pretty manifests already use
897
+ // capitalised labels, but we can't read manifest from here so
898
+ // we humanise the slug ("hero" → "Hero").
899
+ if (!blockElement.hasAttribute('data-builder-block-label')) {
900
+ const type = blockElement.getAttribute('data-builder-block-type') ?? '';
901
+ // Slugs whose Title-Cased form ("Custom Html") doesn't match the
902
+ // manifest's display label. We can't read manifest from here so
903
+ // we keep a small override table for these.
904
+ const SLUG_LABEL_OVERRIDES: Record<string, string> = {
905
+ 'custom-html': 'Custom Embed',
906
+ };
907
+ const override = SLUG_LABEL_OVERRIDES[type];
908
+ const label = override ?? type
909
+ .replace(/[-_]/g, ' ')
910
+ .replace(/\b\w/g, (c) => c.toUpperCase());
911
+ if (label) blockElement.setAttribute('data-builder-block-label', label);
912
+ }
913
+ hoveredBlock.setAttribute('data-builder-hovered', 'true');
914
+ }
915
+ }
916
+
917
+ if (contentElement !== hoveredContent) {
918
+ clearHoveredContent();
919
+ if (contentElement && contentElement !== blockElement) {
920
+ hoveredContent = contentElement;
921
+ hoveredContent.setAttribute('data-builder-content-hovered', 'true');
922
+ }
923
+ }
535
924
  };
536
925
 
537
926
  const handleMouseOut = (event: MouseEvent) => {
538
927
  const relatedTarget = event.relatedTarget;
539
- if (relatedTarget instanceof Node && hovered?.contains(relatedTarget)) return;
540
- clearHovered();
928
+ if (relatedTarget instanceof Node) {
929
+ if (hoveredBlock?.contains(relatedTarget) && hoveredContent?.contains(relatedTarget)) return;
930
+ if (hoveredBlock?.contains(relatedTarget) && !hoveredContent) return;
931
+ }
932
+ clearHoveredBlock();
933
+ clearHoveredContent();
541
934
  };
542
935
 
543
936
  window.addEventListener('mouseover', handleMouseOver, true);
544
937
  window.addEventListener('mouseout', handleMouseOut, true);
545
938
  return () => {
546
- clearHovered();
939
+ clearHoveredBlock();
940
+ clearHoveredContent();
547
941
  window.removeEventListener('mouseover', handleMouseOver, true);
548
942
  window.removeEventListener('mouseout', handleMouseOut, true);
549
943
  };
550
944
  }
945
+
946
+ const INSERTER_HOVER_BAND_PX = 28;
947
+ const INSERTER_CLEAR_DELAY_MS = 150;
948
+
949
+ /**
950
+ * Direct-manipulation bridge: tracks the bounding rect of the selected
951
+ * block (for the floating toolbar), the inter-block gap currently
952
+ * hovered (for the "+" inserter), and inline-text-edit commits. All
953
+ * coordinates are reported in the iframe's viewport space; the
954
+ * dashboard maps them to its overlay layer.
955
+ */
956
+ function installBuilderDirectManipulation(
957
+ postMessage: (message: unknown) => void,
958
+ getRevision: () => number,
959
+ ): () => void {
960
+ // Defensive: test environments may stub document/window without the
961
+ // observer APIs we rely on. In that case we silently no-op so the
962
+ // existing READY/APPLY handshake remains testable.
963
+ if (
964
+ typeof MutationObserver === 'undefined' ||
965
+ typeof window === 'undefined' ||
966
+ typeof document === 'undefined'
967
+ ) {
968
+ return () => {};
969
+ }
970
+
971
+ let trackedBlock: HTMLElement | null = null;
972
+ let lastRectKey: string | null = null;
973
+ let lastInserterKey: string | null = null;
974
+
975
+ const postBlockRect = () => {
976
+ if (!trackedBlock || !trackedBlock.isConnected) {
977
+ if (trackedBlock) {
978
+ postMessage({
979
+ type: 'BLOCK_RECT',
980
+ revision: getRevision(),
981
+ blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
982
+ rect: null,
983
+ });
984
+ }
985
+ trackedBlock = null;
986
+ lastRectKey = null;
987
+ return;
988
+ }
989
+ const rect = trackedBlock.getBoundingClientRect();
990
+ const blockId = trackedBlock.getAttribute('data-builder-block') ?? '';
991
+ const next = `${blockId}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
992
+ if (next === lastRectKey) return;
993
+ lastRectKey = next;
994
+ postMessage({
995
+ type: 'BLOCK_RECT',
996
+ revision: getRevision(),
997
+ blockId,
998
+ rect: {
999
+ top: rect.top,
1000
+ left: rect.left,
1001
+ width: rect.width,
1002
+ height: rect.height,
1003
+ },
1004
+ });
1005
+ };
1006
+
1007
+ const selectionObserver = new MutationObserver(() => {
1008
+ const selected = document.querySelector(BUILDER_SELECTED_SELECTOR);
1009
+ if (selected instanceof HTMLElement) {
1010
+ trackedBlock = selected;
1011
+ postBlockRect();
1012
+ } else if (trackedBlock) {
1013
+ // Selection cleared.
1014
+ postMessage({
1015
+ type: 'BLOCK_RECT',
1016
+ revision: getRevision(),
1017
+ blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
1018
+ rect: null,
1019
+ });
1020
+ trackedBlock = null;
1021
+ lastRectKey = null;
1022
+ }
1023
+ });
1024
+ selectionObserver.observe(document.body, {
1025
+ attributes: true,
1026
+ attributeFilter: ['data-builder-selected'],
1027
+ subtree: true,
1028
+ });
1029
+
1030
+ let rectFrame = 0;
1031
+ const scheduleRectUpdate = () => {
1032
+ if (rectFrame) return;
1033
+ rectFrame = window.requestAnimationFrame(() => {
1034
+ rectFrame = 0;
1035
+ postBlockRect();
1036
+ });
1037
+ };
1038
+
1039
+ window.addEventListener('scroll', scheduleRectUpdate, true);
1040
+ window.addEventListener('resize', scheduleRectUpdate);
1041
+
1042
+ // Inter-block gap inserter: detect mouse near the top/bottom edge of
1043
+ // a tracked block stack. We don't try to be clever about which gap;
1044
+ // every direct ancestor with a list of `[data-builder-block]` children
1045
+ // contributes one gap per pair.
1046
+ let inserterClearTimer: number | null = null;
1047
+
1048
+ const postInserter = (
1049
+ index: number | null,
1050
+ rect: DOMRect | null,
1051
+ ) => {
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);
1089
+ };
1090
+
1091
+ const handleMouseMove = (event: MouseEvent) => {
1092
+ const blocks = Array.from(
1093
+ document.querySelectorAll<HTMLElement>(BUILDER_BLOCK_SELECTOR),
1094
+ ).filter((el) => {
1095
+ // Only consider top-level builder blocks inside the same parent.
1096
+ const parent = el.parentElement;
1097
+ return parent
1098
+ ? Array.from(parent.children).some(
1099
+ (child) =>
1100
+ child instanceof HTMLElement &&
1101
+ child.hasAttribute('data-builder-block'),
1102
+ )
1103
+ : false;
1104
+ });
1105
+
1106
+ for (let i = 0; i < blocks.length - 1; i++) {
1107
+ const top = blocks[i]!.getBoundingClientRect();
1108
+ const bottom = blocks[i + 1]!.getBoundingClientRect();
1109
+ const gapTop = top.bottom;
1110
+ const gapBottom = bottom.top;
1111
+ if (
1112
+ event.clientY >= gapTop - INSERTER_HOVER_BAND_PX / 2 &&
1113
+ event.clientY <= gapBottom + INSERTER_HOVER_BAND_PX / 2 &&
1114
+ event.clientX >= top.left &&
1115
+ event.clientX <= top.right
1116
+ ) {
1117
+ const rect = new DOMRect(
1118
+ top.left,
1119
+ gapTop,
1120
+ top.width,
1121
+ Math.max(gapBottom - gapTop, INSERTER_HOVER_BAND_PX),
1122
+ );
1123
+ postInserter(i + 1, rect);
1124
+ return;
1125
+ }
1126
+ }
1127
+ postInserter(null, null);
1128
+ };
1129
+ window.addEventListener('mousemove', handleMouseMove);
1130
+
1131
+ // Inline edit: double-click on an editable text content element flips
1132
+ // it into contenteditable and gives the caret to the merchant.
1133
+ //
1134
+ // We don't bind the browser's native `dblclick` event because the
1135
+ // dashboard's APPLY_STATE round-trip can rerender the iframe between
1136
+ // the two clicks and the browser silently drops the dblclick.
1137
+ // Instead we track two consecutive `mousedown` events on the same
1138
+ // content element within a 500ms window — same behaviour as the
1139
+ // native event but resilient to React re-mounts in between.
1140
+ let lastDownContent: HTMLElement | null = null;
1141
+ let lastDownAt = 0;
1142
+ const DOUBLE_DOWN_WINDOW_MS = 500;
1143
+
1144
+ const beginInlineEdit = (contentElement: HTMLElement) => {
1145
+ const blockElement = contentElement.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
1146
+ const blockId = blockElement?.getAttribute('data-builder-block');
1147
+ const contentPath = contentElement.getAttribute('data-builder-content');
1148
+ if (!blockId || !contentPath) return false;
1149
+
1150
+ // Skip inline edit for images/links/buttons — those are picked
1151
+ // separately in the inspector. Only run on plain text containers.
1152
+ if (
1153
+ contentElement instanceof HTMLImageElement ||
1154
+ contentElement.closest('a[href], button')
1155
+ ) {
1156
+ return false;
1157
+ }
1158
+
1159
+ contentElement.setAttribute('contenteditable', 'plaintext-only');
1160
+ contentElement.setAttribute('data-builder-inline-edit', 'true');
1161
+ contentElement.focus();
1162
+ const range = document.createRange();
1163
+ range.selectNodeContents(contentElement);
1164
+ const selection = window.getSelection();
1165
+ selection?.removeAllRanges();
1166
+ selection?.addRange(range);
1167
+
1168
+ const finish = (commit: boolean) => {
1169
+ contentElement.removeAttribute('contenteditable');
1170
+ contentElement.removeAttribute('data-builder-inline-edit');
1171
+ contentElement.removeEventListener('blur', onBlur);
1172
+ contentElement.removeEventListener('keydown', onKey);
1173
+ if (commit) {
1174
+ const value = contentElement.textContent ?? '';
1175
+ postMessage({
1176
+ type: 'INLINE_EDIT_COMMIT',
1177
+ revision: getRevision(),
1178
+ blockId,
1179
+ contentPath,
1180
+ value,
1181
+ });
1182
+ }
1183
+ };
1184
+
1185
+ const onBlur = () => finish(true);
1186
+ const onKey = (kev: KeyboardEvent) => {
1187
+ if (kev.key === 'Enter' && !kev.shiftKey) {
1188
+ kev.preventDefault();
1189
+ contentElement.blur();
1190
+ } else if (kev.key === 'Escape') {
1191
+ kev.preventDefault();
1192
+ finish(false);
1193
+ contentElement.blur();
1194
+ }
1195
+ };
1196
+ contentElement.addEventListener('blur', onBlur, { once: true });
1197
+ contentElement.addEventListener('keydown', onKey);
1198
+ return true;
1199
+ };
1200
+
1201
+ const handleMouseDown = (event: MouseEvent) => {
1202
+ const target = event.target;
1203
+ if (!(target instanceof HTMLElement)) {
1204
+ lastDownContent = null;
1205
+ return;
1206
+ }
1207
+ // If we're already in an inline edit on this element, let the
1208
+ // browser handle caret placement normally — no special handling.
1209
+ if (target.closest('[data-builder-inline-edit="true"]')) return;
1210
+
1211
+ const contentElement = target.closest<HTMLElement>(BUILDER_CONTENT_SELECTOR);
1212
+ if (!contentElement) {
1213
+ lastDownContent = null;
1214
+ lastDownAt = 0;
1215
+ return;
1216
+ }
1217
+ const now = Date.now();
1218
+ const isSecondDown =
1219
+ lastDownContent === contentElement && now - lastDownAt < DOUBLE_DOWN_WINDOW_MS;
1220
+ if (!isSecondDown) {
1221
+ lastDownContent = contentElement;
1222
+ lastDownAt = now;
1223
+ return;
1224
+ }
1225
+ // Second mousedown on the same content element within the window —
1226
+ // treat as the user asking to edit inline.
1227
+ lastDownContent = null;
1228
+ lastDownAt = 0;
1229
+ if (!beginInlineEdit(contentElement)) return;
1230
+ event.preventDefault();
1231
+ event.stopPropagation();
1232
+ };
1233
+ window.addEventListener('mousedown', handleMouseDown, true);
1234
+
1235
+ return () => {
1236
+ selectionObserver.disconnect();
1237
+ if (rectFrame) window.cancelAnimationFrame(rectFrame);
1238
+ window.removeEventListener('scroll', scheduleRectUpdate, true);
1239
+ window.removeEventListener('resize', scheduleRectUpdate);
1240
+ window.removeEventListener('mousemove', handleMouseMove);
1241
+ window.removeEventListener('mousedown', handleMouseDown, true);
1242
+ };
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';