@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/dist/react.js CHANGED
@@ -1,15 +1,23 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { BuilderSettingsSchema, PreviewMessageSchema, createEmptyBuilderSettings, migrateLegacyBuilderSettings, } from '@shoppex/builder-contracts';
2
+ import { BuilderSettingsSchema, PreviewMessageSchema, createEmptyBuilderSettings, isTrustedPreviewParentOrigin, migrateLegacyBuilderSettings, normalizeDedicatedPageId, } from '@shoppex/builder-contracts';
3
3
  import { createElement, createContext, useContext, useEffect, useMemo, useRef, useState, } from 'react';
4
4
  import { getBlockSettingValue, getBuilderContentList, getBuilderContentRecord, getBuilderContentString, getBuilderContentValue, } from './content.js';
5
+ import { BUILDER_PREVIEW_REVIEWS } from './preview-fixtures.js';
5
6
  import { createBuilderCss } from './css-vars.js';
7
+ import { builderBlock } from './attributes.js';
6
8
  import { getPageBlocks, getPageLayout, getVisiblePageBlocks } from './layout.js';
9
+ import { getNavigationHeaderSettings, resolveSearchBarSettings, } from './search-bar-settings.js';
7
10
  import { resolveStyleSlotValue } from './style-slots.js';
8
11
  const BUILDER_SETTINGS_WINDOW_KEY = '__SHOPPEX_BUILDER_SETTINGS__';
9
12
  const BUILDER_STATE_EVENT_NAME = 'shoppex:builder-state';
10
13
  const BUILDER_BLOCK_SELECTOR = '[data-builder-block]';
11
14
  const BUILDER_CONTENT_SELECTOR = '[data-builder-content]';
12
15
  const BUILDER_SELECTED_SELECTOR = '[data-builder-selected="true"]';
16
+ const BUILDER_READY_HEALTH = {
17
+ reactMounted: true,
18
+ builderRuntimeProvider: true,
19
+ protocolVersion: 2,
20
+ };
13
21
  const BuilderRuntimeContext = createContext(null);
14
22
  const BuilderBlockContext = createContext(null);
15
23
  export function BuilderRuntimeProvider({ settings, children }) {
@@ -27,10 +35,12 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
27
35
  }, [settings.revision]);
28
36
  useEffect(() => {
29
37
  const currentWindow = window;
30
- const parentOrigin = getPreviewParentOrigin(document.referrer);
38
+ const parentOrigin = getPreviewParentOrigin(window.location, document.referrer);
31
39
  const isTrustedPreviewEmbed = isTrustedBuilderPreviewEmbed(window.location, parentOrigin);
32
40
  const removeInspectorStyles = isTrustedPreviewEmbed ? installBuilderPreviewInspectorStyles() : () => { };
33
41
  const removeHoverInspector = isTrustedPreviewEmbed ? installBuilderPreviewHoverInspector() : () => { };
42
+ let interactionMode = 'edit';
43
+ let removeDirectManipulation = () => { };
34
44
  const postToParent = (event, response) => {
35
45
  const target = event?.source && 'postMessage' in event.source ? event.source : window.parent;
36
46
  if (!target || target === window)
@@ -38,6 +48,13 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
38
48
  const targetOrigin = parentOrigin ?? event?.origin ?? '*';
39
49
  target.postMessage(response, targetOrigin || '*');
40
50
  };
51
+ const postReady = (event) => {
52
+ postToParent(event, {
53
+ type: 'READY',
54
+ revision: settingsRevisionRef.current,
55
+ health: BUILDER_READY_HEALTH,
56
+ });
57
+ };
41
58
  const applySettings = (input) => {
42
59
  const parsed = BuilderSettingsSchema.safeParse(input);
43
60
  if (!parsed.success)
@@ -65,7 +82,7 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
65
82
  return;
66
83
  const message = parsed.data;
67
84
  if (message.type === 'REQUEST_READY') {
68
- postToParent(event, { type: 'READY', revision: settingsRevisionRef.current });
85
+ postReady(event);
69
86
  return;
70
87
  }
71
88
  if (message.type === 'APPLY_STATE') {
@@ -81,19 +98,42 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
81
98
  }
82
99
  if (message.type === 'RELOAD') {
83
100
  postToParent(event, { type: 'APPLIED', revision: message.revision });
84
- window.setTimeout(() => window.location.reload(), 0);
101
+ const previewReloadTarget = resolvePreviewReloadTarget(window.location, currentWindow.__SHOPPEX_PREVIEW_SESSION_PATH__);
102
+ window.setTimeout(() => {
103
+ if (previewReloadTarget) {
104
+ window.location.href = previewReloadTarget;
105
+ return;
106
+ }
107
+ window.location.reload();
108
+ }, 0);
85
109
  return;
86
110
  }
87
111
  if (message.type === 'SELECT_ELEMENT') {
88
112
  selectBuilderElement(message.selection.blockId);
113
+ return;
114
+ }
115
+ if (message.type === 'SET_INTERACTION_MODE') {
116
+ interactionMode = message.mode;
117
+ document.documentElement.setAttribute('data-builder-interaction-mode', interactionMode);
118
+ return;
89
119
  }
90
120
  };
91
121
  const handleBuilderClick = (event) => {
92
122
  if (!isTrustedPreviewEmbed)
93
123
  return;
124
+ // In "preview" interaction mode let the storefront react to clicks
125
+ // naturally so the merchant can test buy-now flows or anchor links.
126
+ if (interactionMode === 'preview')
127
+ return;
94
128
  const target = event.target;
95
129
  if (!(target instanceof Element))
96
130
  return;
131
+ // Once an element is in inline-edit mode, clicks on it should
132
+ // place the caret instead of re-firing block selection. The
133
+ // mousedown handler in installBuilderDirectManipulation has
134
+ // already validated this is a legitimate edit interaction.
135
+ if (target.closest('[data-builder-inline-edit="true"]'))
136
+ return;
97
137
  const blockElement = target.closest(BUILDER_BLOCK_SELECTOR);
98
138
  if (!blockElement)
99
139
  return;
@@ -108,18 +148,52 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
108
148
  selection,
109
149
  });
110
150
  };
111
- if (isTrustedPreviewEmbed) {
112
- postToParent(null, { type: 'READY', revision: settingsRevisionRef.current });
113
- }
151
+ const postPreviewError = (source, error) => {
152
+ if (!isTrustedPreviewEmbed)
153
+ return;
154
+ const normalized = normalizePreviewRuntimeError(error);
155
+ postToParent(null, {
156
+ type: 'PREVIEW_ERROR',
157
+ revision: settingsRevisionRef.current,
158
+ message: normalized.message,
159
+ ...(normalized.stack ? { stack: normalized.stack } : {}),
160
+ source,
161
+ phase: 'runtime',
162
+ diagnostics: {
163
+ name: normalized.name,
164
+ href: window.location.href,
165
+ referrer: document.referrer || undefined,
166
+ parentOrigin: parentOrigin ?? undefined,
167
+ previewMode: new URLSearchParams(window.location.search).get('shoppex-preview-mode') ?? undefined,
168
+ userAgent: navigator.userAgent,
169
+ },
170
+ });
171
+ };
172
+ const handleRuntimeError = (event) => {
173
+ postPreviewError('error', event.error ?? event.message);
174
+ };
175
+ const handleUnhandledRejection = (event) => {
176
+ postPreviewError('unhandledrejection', event.reason);
177
+ };
114
178
  window.addEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
115
179
  window.addEventListener('message', handlePreviewMessage);
116
180
  window.addEventListener('click', handleBuilderClick, true);
181
+ window.addEventListener('error', handleRuntimeError);
182
+ window.addEventListener('unhandledrejection', handleUnhandledRejection);
183
+ if (isTrustedPreviewEmbed) {
184
+ window.__SHOPPEX_PREVIEW_DISABLE_BOOTSTRAP_ERROR_BRIDGE__?.();
185
+ removeDirectManipulation = installBuilderDirectManipulation((message) => postToParent(null, message), () => settingsRevisionRef.current);
186
+ postReady(null);
187
+ }
117
188
  return () => {
118
189
  removeInspectorStyles();
119
190
  removeHoverInspector();
191
+ removeDirectManipulation();
120
192
  window.removeEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
121
193
  window.removeEventListener('message', handlePreviewMessage);
122
194
  window.removeEventListener('click', handleBuilderClick, true);
195
+ window.removeEventListener('error', handleRuntimeError);
196
+ window.removeEventListener('unhandledrejection', handleUnhandledRejection);
123
197
  };
124
198
  }, []);
125
199
  return _jsx(BuilderRuntimeProvider, { settings: settings, children: children });
@@ -145,11 +219,32 @@ export function BuilderPage({ pageId, blocks, registry, context, fallback = null
145
219
  return (_jsx(_Fragment, { children: pageBlocks.map((block) => {
146
220
  const Component = registry[block.type];
147
221
  if (!Component) {
148
- return fallback;
222
+ return (_jsx(BuilderBlockProvider, { block: block, children: fallback ?? renderMissingBuilderBlock(pageId, block) }, block.id));
149
223
  }
150
224
  return (_jsx(BuilderBlockProvider, { block: block, children: _jsx(Component, { block: block, context: context }) }, block.id));
151
225
  }) }));
152
226
  }
227
+ function renderMissingBuilderBlock(pageId, block) {
228
+ if (!isBuilderPreviewRuntime()) {
229
+ return null;
230
+ }
231
+ return createElement('div', {
232
+ 'data-page-id': pageId,
233
+ 'data-builder-block': block.id,
234
+ 'data-builder-block-type': block.type,
235
+ 'data-builder-runtime-error': 'missing-block-component',
236
+ style: {
237
+ margin: '12px 0',
238
+ border: '1px solid #dc2626',
239
+ borderRadius: '8px',
240
+ background: '#fef2f2',
241
+ color: '#7f1d1d',
242
+ padding: '12px',
243
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
244
+ fontSize: '13px',
245
+ },
246
+ }, `Missing Builder component for block "${block.type}".`);
247
+ }
153
248
  export function useBuilderRuntime() {
154
249
  const context = useContext(BuilderRuntimeContext);
155
250
  if (!context) {
@@ -202,6 +297,51 @@ export function useBuilderPageBlocks(pageId) {
202
297
  export function useVisibleBuilderPageBlocks(pageId) {
203
298
  return getVisiblePageBlocks(useBuilderRuntime().settings, pageId);
204
299
  }
300
+ export function useThemePageBlocks(pageId, defaultOrder) {
301
+ const { settings } = useBuilderRuntime();
302
+ const canonicalPageId = normalizeDedicatedPageId(pageId);
303
+ return useMemo(() => {
304
+ const page = settings.theme.layout[canonicalPageId];
305
+ if (page) {
306
+ return page.blocks.filter((block) => block.visible);
307
+ }
308
+ return defaultOrder.map((type) => ({ id: type, type, visible: true, settings: {} }));
309
+ }, [canonicalPageId, defaultOrder, settings.theme.layout]);
310
+ }
311
+ export function useThemePageBlockAttributes(pageId, defaultOrder) {
312
+ const canonicalPageId = normalizeDedicatedPageId(pageId);
313
+ const blocks = useThemePageBlocks(pageId, defaultOrder);
314
+ const block = blocks[0];
315
+ return useMemo(() => ({
316
+ 'data-page-id': canonicalPageId,
317
+ ...(block ? builderBlock(block.id, block.type) : {}),
318
+ }), [block, canonicalPageId]);
319
+ }
320
+ export function DedicatedBuilderPage({ pageId, defaultBlockOrder, as, children, ...rest }) {
321
+ const attrs = useThemePageBlockAttributes(pageId, defaultBlockOrder);
322
+ const Component = (as ?? 'section');
323
+ return createElement(Component, { ...attrs, ...rest }, children);
324
+ }
325
+ export function useBuilderPreviewReviews(input) {
326
+ const fixtures = input.fixtures ?? BUILDER_PREVIEW_REVIEWS;
327
+ const shouldUseFixtures = isBuilderPreviewRuntime() && Boolean(input.error);
328
+ return useMemo(() => {
329
+ if (!shouldUseFixtures) {
330
+ return {
331
+ reviews: input.reviews,
332
+ isLoading: input.isLoading,
333
+ error: input.error,
334
+ isUsingFixtures: false,
335
+ };
336
+ }
337
+ return {
338
+ reviews: fixtures,
339
+ isLoading: false,
340
+ error: null,
341
+ isUsingFixtures: true,
342
+ };
343
+ }, [fixtures, input.error, input.isLoading, input.reviews, shouldUseFixtures]);
344
+ }
205
345
  export function useBuilderStyleSlot(slotId, input = {}) {
206
346
  return resolveStyleSlotValue(useBuilderRuntime().settings, slotId, input);
207
347
  }
@@ -232,6 +372,47 @@ function getNestedBuilderSetting(record, path) {
232
372
  }
233
373
  return current;
234
374
  }
375
+ export function useSearchBarSettings(input) {
376
+ const runtime = useBuilderRuntime();
377
+ const headerSettings = useMemo(() => input.headerSettings ?? getNavigationHeaderSettings(runtime.settings), [input.headerSettings, runtime.settings]);
378
+ const heroPlaceholder = useBuilderContentValue('hero.search.placeholder');
379
+ const heroBackground = useBuilderContentValue('hero.search.background');
380
+ const heroBorderColor = useBuilderContentValue('hero.search.borderColor');
381
+ const heroBorderRadius = useBuilderContentValue('hero.search.borderRadius');
382
+ const heroMaxWidth = useBuilderContentValue('hero.search.maxWidth');
383
+ const heroShowShortcut = useBuilderContentValue('hero.search.showShortcut');
384
+ const heroShow = useBuilderContentValue('hero.search.show');
385
+ return useMemo(() => resolveSearchBarSettings({
386
+ variant: input.variant,
387
+ headerSettings,
388
+ heroValues: {
389
+ 'hero.search.placeholder': heroPlaceholder,
390
+ 'hero.search.background': heroBackground,
391
+ 'hero.search.borderColor': heroBorderColor,
392
+ 'hero.search.borderRadius': heroBorderRadius,
393
+ 'hero.search.maxWidth': heroMaxWidth,
394
+ 'hero.search.showShortcut': heroShowShortcut,
395
+ 'hero.search.show': heroShow,
396
+ },
397
+ }), [
398
+ headerSettings,
399
+ heroBackground,
400
+ heroBorderColor,
401
+ heroBorderRadius,
402
+ heroMaxWidth,
403
+ heroPlaceholder,
404
+ heroShow,
405
+ heroShowShortcut,
406
+ input.variant,
407
+ ]);
408
+ }
409
+ export { buildSearchShellStyle, getNavigationHeaderSettings, resolveSearchBarSettings, } from './search-bar-settings.js';
410
+ export function isBuilderPreviewRuntime() {
411
+ if (typeof window === 'undefined') {
412
+ return false;
413
+ }
414
+ return window.location.search.includes('shoppex-preview-mode=theme');
415
+ }
235
416
  function parseInitialBuilderSettings(input) {
236
417
  const parsed = BuilderSettingsSchema.safeParse(input);
237
418
  if (parsed.success)
@@ -270,22 +451,53 @@ function parseBuilderRevision(input) {
270
451
  function isRecord(value) {
271
452
  return typeof value === 'object' && value !== null && !Array.isArray(value);
272
453
  }
273
- function getPreviewParentOrigin(referrer) {
274
- if (!referrer)
454
+ export function resolvePreviewReloadTarget(location, sessionPath) {
455
+ if (typeof sessionPath !== 'string' || !sessionPath.startsWith('/s/')) {
456
+ return null;
457
+ }
458
+ return `${sessionPath}${location.search}${location.hash}`;
459
+ }
460
+ function normalizePreviewRuntimeError(error) {
461
+ if (error instanceof Error) {
462
+ return {
463
+ message: error.message || error.name || 'Preview runtime error',
464
+ name: error.name,
465
+ ...(typeof error.stack === 'string' && error.stack ? { stack: error.stack } : {}),
466
+ };
467
+ }
468
+ if (typeof error === 'string') {
469
+ return { message: error || 'Preview runtime error' };
470
+ }
471
+ if (isRecord(error)) {
472
+ const message = typeof error.message === 'string' && error.message
473
+ ? error.message
474
+ : 'Preview runtime error';
475
+ const stack = typeof error.stack === 'string' && error.stack ? error.stack : undefined;
476
+ const name = typeof error.name === 'string' && error.name ? error.name : undefined;
477
+ return { message, ...(stack ? { stack } : {}), ...(name ? { name } : {}) };
478
+ }
479
+ return { message: 'Preview runtime error' };
480
+ }
481
+ function parseOrigin(value) {
482
+ if (!value)
275
483
  return null;
276
484
  try {
277
- return new URL(referrer).origin;
485
+ return new URL(value).origin;
278
486
  }
279
487
  catch {
280
488
  return null;
281
489
  }
282
490
  }
491
+ function getPreviewParentOrigin(location, referrer) {
492
+ const explicitOrigin = new URLSearchParams(location.search).get('shoppex-preview-parent-origin');
493
+ return parseOrigin(explicitOrigin) ?? parseOrigin(referrer);
494
+ }
283
495
  function isTrustedBuilderPreviewEmbed(location, parentOrigin) {
284
496
  if (window.parent === window || !parentOrigin)
285
497
  return false;
286
498
  if (!hasBuilderPreviewMode(location))
287
499
  return false;
288
- return isTrustedBuilderPreviewParentOrigin(parentOrigin);
500
+ return isTrustedPreviewParentOrigin(parentOrigin);
289
501
  }
290
502
  function hasBuilderPreviewMode(location) {
291
503
  const searchParams = new URLSearchParams(location.search);
@@ -293,23 +505,6 @@ function hasBuilderPreviewMode(location) {
293
505
  || searchParams.get('shoppex-preview-mode') === 'builder'
294
506
  || searchParams.get('shoppex-preview') === 'builder');
295
507
  }
296
- function isTrustedBuilderPreviewParentOrigin(origin) {
297
- try {
298
- const parsed = new URL(origin);
299
- const hostname = parsed.hostname.toLowerCase();
300
- return (hostname === 'dashboard.shoppex.io'
301
- || hostname === 'dashboard.shoppex.test'
302
- || hostname === 'localhost'
303
- || hostname === '127.0.0.1'
304
- || hostname === '::1'
305
- || hostname.endsWith('.localhost')
306
- || hostname.endsWith('.vercel.app')
307
- || hostname.endsWith('.vercel.run'));
308
- }
309
- catch {
310
- return false;
311
- }
312
- }
313
508
  function selectBuilderElement(blockId) {
314
509
  document
315
510
  .querySelectorAll(BUILDER_SELECTED_SELECTOR)
@@ -320,7 +515,9 @@ function selectBuilderElement(blockId) {
320
515
  if (!target)
321
516
  return;
322
517
  target.setAttribute('data-builder-selected', 'true');
323
- target.scrollIntoView({ behavior: 'smooth', block: 'center' });
518
+ const pageId = target.getAttribute('data-page-id');
519
+ const scrollBlock = pageId === 'footer' ? 'end' : pageId === 'navigation' ? 'start' : 'nearest';
520
+ target.scrollIntoView({ behavior: 'smooth', block: scrollBlock });
324
521
  }
325
522
  function createBuilderSelection(blockElement, target) {
326
523
  const contentElement = target.closest(BUILDER_CONTENT_SELECTOR);
@@ -361,48 +558,410 @@ function installBuilderPreviewInspectorStyles() {
361
558
  position: relative;
362
559
  }
363
560
  [data-builder-block][data-builder-selected="true"] {
364
- outline: 2px solid #2563eb;
561
+ outline: 1px solid rgba(124, 58, 237, 0.7);
365
562
  outline-offset: 4px;
366
563
  }
367
564
  [data-builder-block][data-builder-hovered="true"] {
368
- outline: 1px dashed #2563eb;
565
+ outline: 1px dashed rgba(124, 58, 237, 0.5);
369
566
  outline-offset: 4px;
370
567
  cursor: pointer;
371
568
  }
569
+ /* Block-name tooltip in the upper-left corner of the hovered block.
570
+ Driven by a data-builder-block-label attribute the runtime sets
571
+ from manifest.blocks[type].label when available, falling back to
572
+ the block-type slug. */
573
+ [data-builder-block][data-builder-hovered="true"]::before {
574
+ content: attr(data-builder-block-label);
575
+ position: absolute;
576
+ top: -22px;
577
+ left: 0;
578
+ padding: 2px 6px;
579
+ font-size: 11px;
580
+ font-weight: 500;
581
+ line-height: 1.3;
582
+ color: #ffffff;
583
+ background: #7c3aed;
584
+ border-radius: 4px;
585
+ pointer-events: none;
586
+ white-space: nowrap;
587
+ z-index: 999;
588
+ }
589
+ /* Sub-element hover: thinner outline so a hover on a text or image
590
+ inside a block highlights only that target instead of the whole
591
+ block. Suppressed while the parent block is selected so the
592
+ merchant doesn't see double outlines. */
593
+ [data-builder-content][data-builder-content-hovered="true"] {
594
+ outline: 1px dashed rgba(124, 58, 237, 0.5);
595
+ outline-offset: 2px;
596
+ cursor: pointer;
597
+ }
598
+ [data-builder-block][data-builder-selected="true"] [data-builder-content][data-builder-content-hovered="true"] {
599
+ outline: none;
600
+ }
601
+ /* Inline-edit affordance: hover over editable text content reveals a
602
+ text cursor + subtle brand underline so the merchant can see what
603
+ a double-click would target. Images, links, and buttons stay
604
+ outside this rule — those have their own picker affordances. */
605
+ [data-builder-content][data-builder-content-hovered="true"]:not(img):not(a[href]):not(:has(a[href])):not(:has(button)) {
606
+ text-decoration: underline;
607
+ text-decoration-color: rgba(124, 58, 237, 0.55);
608
+ text-decoration-thickness: 2px;
609
+ text-underline-offset: 4px;
610
+ cursor: text;
611
+ }
612
+ /* Active inline-edit state: replaces the dashed outline with a
613
+ brand-tinted ring so the merchant gets unambiguous focus
614
+ feedback while typing. */
615
+ [data-builder-content][data-builder-inline-edit="true"] {
616
+ outline: 2px solid #7c3aed !important;
617
+ outline-offset: 4px !important;
618
+ border-radius: 4px;
619
+ background: rgba(124, 58, 237, 0.06);
620
+ text-decoration: none !important;
621
+ cursor: text !important;
622
+ caret-color: #7c3aed;
623
+ }
624
+ [data-builder-content][data-builder-inline-edit="true"]:focus {
625
+ outline: 2px solid #7c3aed !important;
626
+ outline-offset: 4px !important;
627
+ }
372
628
  `;
373
629
  document.head.appendChild(style);
374
630
  return () => style.remove();
375
631
  }
376
632
  function installBuilderPreviewHoverInspector() {
377
- let hovered = null;
378
- const clearHovered = () => {
379
- hovered?.removeAttribute('data-builder-hovered');
380
- hovered = null;
633
+ let hoveredBlock = null;
634
+ let hoveredContent = null;
635
+ const clearHoveredBlock = () => {
636
+ hoveredBlock?.removeAttribute('data-builder-hovered');
637
+ hoveredBlock = null;
638
+ };
639
+ const clearHoveredContent = () => {
640
+ hoveredContent?.removeAttribute('data-builder-content-hovered');
641
+ hoveredContent = null;
381
642
  };
382
643
  const handleMouseOver = (event) => {
383
644
  const target = event.target;
384
645
  if (!(target instanceof Element))
385
646
  return;
386
647
  const blockElement = target.closest(BUILDER_BLOCK_SELECTOR);
387
- if (blockElement === hovered)
388
- return;
389
- clearHovered();
390
- if (!blockElement)
391
- return;
392
- hovered = blockElement;
393
- hovered.setAttribute('data-builder-hovered', 'true');
648
+ const contentElement = target.closest(BUILDER_CONTENT_SELECTOR);
649
+ if (blockElement !== hoveredBlock) {
650
+ clearHoveredBlock();
651
+ if (blockElement) {
652
+ hoveredBlock = blockElement;
653
+ // Drive the ::before tooltip via a label attribute. Derive
654
+ // it from the block-type slug — pretty manifests already use
655
+ // capitalised labels, but we can't read manifest from here so
656
+ // we humanise the slug ("hero" → "Hero").
657
+ if (!blockElement.hasAttribute('data-builder-block-label')) {
658
+ const type = blockElement.getAttribute('data-builder-block-type') ?? '';
659
+ // Slugs whose Title-Cased form ("Custom Html") doesn't match the
660
+ // manifest's display label. We can't read manifest from here so
661
+ // we keep a small override table for these.
662
+ const SLUG_LABEL_OVERRIDES = {
663
+ 'custom-html': 'Custom Embed',
664
+ };
665
+ const override = SLUG_LABEL_OVERRIDES[type];
666
+ const label = override ?? type
667
+ .replace(/[-_]/g, ' ')
668
+ .replace(/\b\w/g, (c) => c.toUpperCase());
669
+ if (label)
670
+ blockElement.setAttribute('data-builder-block-label', label);
671
+ }
672
+ hoveredBlock.setAttribute('data-builder-hovered', 'true');
673
+ }
674
+ }
675
+ if (contentElement !== hoveredContent) {
676
+ clearHoveredContent();
677
+ if (contentElement && contentElement !== blockElement) {
678
+ hoveredContent = contentElement;
679
+ hoveredContent.setAttribute('data-builder-content-hovered', 'true');
680
+ }
681
+ }
394
682
  };
395
683
  const handleMouseOut = (event) => {
396
684
  const relatedTarget = event.relatedTarget;
397
- if (relatedTarget instanceof Node && hovered?.contains(relatedTarget))
398
- return;
399
- clearHovered();
685
+ if (relatedTarget instanceof Node) {
686
+ if (hoveredBlock?.contains(relatedTarget) && hoveredContent?.contains(relatedTarget))
687
+ return;
688
+ if (hoveredBlock?.contains(relatedTarget) && !hoveredContent)
689
+ return;
690
+ }
691
+ clearHoveredBlock();
692
+ clearHoveredContent();
400
693
  };
401
694
  window.addEventListener('mouseover', handleMouseOver, true);
402
695
  window.addEventListener('mouseout', handleMouseOut, true);
403
696
  return () => {
404
- clearHovered();
697
+ clearHoveredBlock();
698
+ clearHoveredContent();
405
699
  window.removeEventListener('mouseover', handleMouseOver, true);
406
700
  window.removeEventListener('mouseout', handleMouseOut, true);
407
701
  };
408
702
  }
703
+ const INSERTER_HOVER_BAND_PX = 28;
704
+ const INSERTER_CLEAR_DELAY_MS = 150;
705
+ /**
706
+ * Direct-manipulation bridge: tracks the bounding rect of the selected
707
+ * block (for the floating toolbar), the inter-block gap currently
708
+ * hovered (for the "+" inserter), and inline-text-edit commits. All
709
+ * coordinates are reported in the iframe's viewport space; the
710
+ * dashboard maps them to its overlay layer.
711
+ */
712
+ function installBuilderDirectManipulation(postMessage, getRevision) {
713
+ // Defensive: test environments may stub document/window without the
714
+ // observer APIs we rely on. In that case we silently no-op so the
715
+ // existing READY/APPLY handshake remains testable.
716
+ if (typeof MutationObserver === 'undefined' ||
717
+ typeof window === 'undefined' ||
718
+ typeof document === 'undefined') {
719
+ return () => { };
720
+ }
721
+ let trackedBlock = null;
722
+ let lastRectKey = null;
723
+ let lastInserterKey = null;
724
+ const postBlockRect = () => {
725
+ if (!trackedBlock || !trackedBlock.isConnected) {
726
+ if (trackedBlock) {
727
+ postMessage({
728
+ type: 'BLOCK_RECT',
729
+ revision: getRevision(),
730
+ blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
731
+ rect: null,
732
+ });
733
+ }
734
+ trackedBlock = null;
735
+ lastRectKey = null;
736
+ return;
737
+ }
738
+ const rect = trackedBlock.getBoundingClientRect();
739
+ const blockId = trackedBlock.getAttribute('data-builder-block') ?? '';
740
+ const next = `${blockId}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
741
+ if (next === lastRectKey)
742
+ return;
743
+ lastRectKey = next;
744
+ postMessage({
745
+ type: 'BLOCK_RECT',
746
+ revision: getRevision(),
747
+ blockId,
748
+ rect: {
749
+ top: rect.top,
750
+ left: rect.left,
751
+ width: rect.width,
752
+ height: rect.height,
753
+ },
754
+ });
755
+ };
756
+ const selectionObserver = new MutationObserver(() => {
757
+ const selected = document.querySelector(BUILDER_SELECTED_SELECTOR);
758
+ if (selected instanceof HTMLElement) {
759
+ trackedBlock = selected;
760
+ postBlockRect();
761
+ }
762
+ else if (trackedBlock) {
763
+ // Selection cleared.
764
+ postMessage({
765
+ type: 'BLOCK_RECT',
766
+ revision: getRevision(),
767
+ blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
768
+ rect: null,
769
+ });
770
+ trackedBlock = null;
771
+ lastRectKey = null;
772
+ }
773
+ });
774
+ selectionObserver.observe(document.body, {
775
+ attributes: true,
776
+ attributeFilter: ['data-builder-selected'],
777
+ subtree: true,
778
+ });
779
+ let rectFrame = 0;
780
+ const scheduleRectUpdate = () => {
781
+ if (rectFrame)
782
+ return;
783
+ rectFrame = window.requestAnimationFrame(() => {
784
+ rectFrame = 0;
785
+ postBlockRect();
786
+ });
787
+ };
788
+ window.addEventListener('scroll', scheduleRectUpdate, true);
789
+ window.addEventListener('resize', scheduleRectUpdate);
790
+ // Inter-block gap inserter: detect mouse near the top/bottom edge of
791
+ // a tracked block stack. We don't try to be clever about which gap;
792
+ // every direct ancestor with a list of `[data-builder-block]` children
793
+ // contributes one gap per pair.
794
+ let inserterClearTimer = null;
795
+ const postInserter = (index, rect) => {
796
+ const emitInserter = () => {
797
+ const key = index === null || !rect
798
+ ? '__none__'
799
+ : `${index}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
800
+ if (key === lastInserterKey)
801
+ return;
802
+ lastInserterKey = key;
803
+ postMessage({
804
+ type: 'INSERTER_HOVER',
805
+ revision: getRevision(),
806
+ index,
807
+ rect: rect === null
808
+ ? null
809
+ : {
810
+ top: rect.top,
811
+ left: rect.left,
812
+ width: rect.width,
813
+ height: rect.height,
814
+ },
815
+ });
816
+ };
817
+ if (index !== null && rect !== null) {
818
+ if (inserterClearTimer !== null) {
819
+ window.clearTimeout(inserterClearTimer);
820
+ inserterClearTimer = null;
821
+ }
822
+ emitInserter();
823
+ return;
824
+ }
825
+ if (inserterClearTimer !== null)
826
+ return;
827
+ inserterClearTimer = window.setTimeout(() => {
828
+ inserterClearTimer = null;
829
+ emitInserter();
830
+ }, INSERTER_CLEAR_DELAY_MS);
831
+ };
832
+ const handleMouseMove = (event) => {
833
+ const blocks = Array.from(document.querySelectorAll(BUILDER_BLOCK_SELECTOR)).filter((el) => {
834
+ // Only consider top-level builder blocks inside the same parent.
835
+ const parent = el.parentElement;
836
+ return parent
837
+ ? Array.from(parent.children).some((child) => child instanceof HTMLElement &&
838
+ child.hasAttribute('data-builder-block'))
839
+ : false;
840
+ });
841
+ for (let i = 0; i < blocks.length - 1; i++) {
842
+ const top = blocks[i].getBoundingClientRect();
843
+ const bottom = blocks[i + 1].getBoundingClientRect();
844
+ const gapTop = top.bottom;
845
+ const gapBottom = bottom.top;
846
+ if (event.clientY >= gapTop - INSERTER_HOVER_BAND_PX / 2 &&
847
+ event.clientY <= gapBottom + INSERTER_HOVER_BAND_PX / 2 &&
848
+ event.clientX >= top.left &&
849
+ event.clientX <= top.right) {
850
+ const rect = new DOMRect(top.left, gapTop, top.width, Math.max(gapBottom - gapTop, INSERTER_HOVER_BAND_PX));
851
+ postInserter(i + 1, rect);
852
+ return;
853
+ }
854
+ }
855
+ postInserter(null, null);
856
+ };
857
+ window.addEventListener('mousemove', handleMouseMove);
858
+ // Inline edit: double-click on an editable text content element flips
859
+ // it into contenteditable and gives the caret to the merchant.
860
+ //
861
+ // We don't bind the browser's native `dblclick` event because the
862
+ // dashboard's APPLY_STATE round-trip can rerender the iframe between
863
+ // the two clicks and the browser silently drops the dblclick.
864
+ // Instead we track two consecutive `mousedown` events on the same
865
+ // content element within a 500ms window — same behaviour as the
866
+ // native event but resilient to React re-mounts in between.
867
+ let lastDownContent = null;
868
+ let lastDownAt = 0;
869
+ const DOUBLE_DOWN_WINDOW_MS = 500;
870
+ const beginInlineEdit = (contentElement) => {
871
+ const blockElement = contentElement.closest(BUILDER_BLOCK_SELECTOR);
872
+ const blockId = blockElement?.getAttribute('data-builder-block');
873
+ const contentPath = contentElement.getAttribute('data-builder-content');
874
+ if (!blockId || !contentPath)
875
+ return false;
876
+ // Skip inline edit for images/links/buttons — those are picked
877
+ // separately in the inspector. Only run on plain text containers.
878
+ if (contentElement instanceof HTMLImageElement ||
879
+ contentElement.closest('a[href], button')) {
880
+ return false;
881
+ }
882
+ contentElement.setAttribute('contenteditable', 'plaintext-only');
883
+ contentElement.setAttribute('data-builder-inline-edit', 'true');
884
+ contentElement.focus();
885
+ const range = document.createRange();
886
+ range.selectNodeContents(contentElement);
887
+ const selection = window.getSelection();
888
+ selection?.removeAllRanges();
889
+ selection?.addRange(range);
890
+ const finish = (commit) => {
891
+ contentElement.removeAttribute('contenteditable');
892
+ contentElement.removeAttribute('data-builder-inline-edit');
893
+ contentElement.removeEventListener('blur', onBlur);
894
+ contentElement.removeEventListener('keydown', onKey);
895
+ if (commit) {
896
+ const value = contentElement.textContent ?? '';
897
+ postMessage({
898
+ type: 'INLINE_EDIT_COMMIT',
899
+ revision: getRevision(),
900
+ blockId,
901
+ contentPath,
902
+ value,
903
+ });
904
+ }
905
+ };
906
+ const onBlur = () => finish(true);
907
+ const onKey = (kev) => {
908
+ if (kev.key === 'Enter' && !kev.shiftKey) {
909
+ kev.preventDefault();
910
+ contentElement.blur();
911
+ }
912
+ else if (kev.key === 'Escape') {
913
+ kev.preventDefault();
914
+ finish(false);
915
+ contentElement.blur();
916
+ }
917
+ };
918
+ contentElement.addEventListener('blur', onBlur, { once: true });
919
+ contentElement.addEventListener('keydown', onKey);
920
+ return true;
921
+ };
922
+ const handleMouseDown = (event) => {
923
+ const target = event.target;
924
+ if (!(target instanceof HTMLElement)) {
925
+ lastDownContent = null;
926
+ return;
927
+ }
928
+ // If we're already in an inline edit on this element, let the
929
+ // browser handle caret placement normally — no special handling.
930
+ if (target.closest('[data-builder-inline-edit="true"]'))
931
+ return;
932
+ const contentElement = target.closest(BUILDER_CONTENT_SELECTOR);
933
+ if (!contentElement) {
934
+ lastDownContent = null;
935
+ lastDownAt = 0;
936
+ return;
937
+ }
938
+ const now = Date.now();
939
+ const isSecondDown = lastDownContent === contentElement && now - lastDownAt < DOUBLE_DOWN_WINDOW_MS;
940
+ if (!isSecondDown) {
941
+ lastDownContent = contentElement;
942
+ lastDownAt = now;
943
+ return;
944
+ }
945
+ // Second mousedown on the same content element within the window —
946
+ // treat as the user asking to edit inline.
947
+ lastDownContent = null;
948
+ lastDownAt = 0;
949
+ if (!beginInlineEdit(contentElement))
950
+ return;
951
+ event.preventDefault();
952
+ event.stopPropagation();
953
+ };
954
+ window.addEventListener('mousedown', handleMouseDown, true);
955
+ return () => {
956
+ selectionObserver.disconnect();
957
+ if (rectFrame)
958
+ window.cancelAnimationFrame(rectFrame);
959
+ window.removeEventListener('scroll', scheduleRectUpdate, true);
960
+ window.removeEventListener('resize', scheduleRectUpdate);
961
+ window.removeEventListener('mousemove', handleMouseMove);
962
+ window.removeEventListener('mousedown', handleMouseDown, true);
963
+ };
964
+ }
965
+ export { getBuilderBlockSettingText, getBuilderProductBlockAttributes, getLayoutPageBlockAttributes, getProductBlockText, getProductPageBlockAttributes, } from './product-page.js';
966
+ export { createStandardProductBlockRegistry, splitStandardProductPageBlocks, buildStandardProductInfoTabs, resolveStandardBuyBoxLabels, resolveStandardDetailsLabels, resolveStandardRelatedProductsTitle, resolveScopedBlockSettingText, STANDARD_PRODUCT_BLOCK_TYPES, STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES, STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES, } from './standard-product-blocks.js';
967
+ export { getBuilderPreviewReviewFixtures } from './preview-fixtures.js';