@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/layout.ts CHANGED
@@ -1,4 +1,17 @@
1
- import type { BlockInstance, BuilderSettings, PageLayout, ThemeManifest } from '@shoppex/builder-contracts';
1
+ import {
2
+ getThemePageBlockOrderFromManifest as resolveThemePageBlockOrder,
3
+ type BlockInstance,
4
+ type BuilderSettings,
5
+ type PageLayout,
6
+ type ThemeManifest,
7
+ type ThemePageBlockOrderPage,
8
+ } from '@shoppex/builder-contracts';
9
+
10
+ export type ThemePageBlockOrderManifest = {
11
+ pages?: Record<string, ThemePageBlockOrderPage>;
12
+ };
13
+
14
+ export { resolveThemePageBlockOrder as getThemePageBlockOrderFromManifest };
2
15
 
3
16
  export function getPageLayout(settings: BuilderSettings, pageId: string): PageLayout {
4
17
  return settings.theme.layout[pageId] ?? { blocks: [] };
@@ -0,0 +1,56 @@
1
+ export type BuilderPreviewReview = {
2
+ id: string;
3
+ author: string | null;
4
+ comment: string | null;
5
+ rating: number | null;
6
+ created_at: string;
7
+ is_automated?: boolean;
8
+ };
9
+
10
+ export type BuilderPreviewFaqItem = {
11
+ question: string;
12
+ answer: string;
13
+ };
14
+
15
+ export const BUILDER_PREVIEW_REVIEWS: BuilderPreviewReview[] = [
16
+ {
17
+ id: 'preview-review-1',
18
+ author: 'Alex M.',
19
+ comment: 'Instant delivery and clear instructions. Would buy again.',
20
+ rating: 5,
21
+ created_at: '2026-04-12T10:00:00.000Z',
22
+ },
23
+ {
24
+ id: 'preview-review-2',
25
+ author: 'Jamie R.',
26
+ comment: 'Support answered quickly when I had a setup question.',
27
+ rating: 5,
28
+ created_at: '2026-04-03T14:30:00.000Z',
29
+ },
30
+ {
31
+ id: 'preview-review-3',
32
+ author: 'Taylor S.',
33
+ comment: 'Smooth checkout experience and exactly what was advertised.',
34
+ rating: 4,
35
+ created_at: '2026-03-22T09:15:00.000Z',
36
+ },
37
+ ];
38
+
39
+ export function getBuilderPreviewReviewFixtures<T = BuilderPreviewReview>(): T[] {
40
+ return BUILDER_PREVIEW_REVIEWS as T[];
41
+ }
42
+
43
+ export const BUILDER_PREVIEW_FAQ_ITEMS: BuilderPreviewFaqItem[] = [
44
+ {
45
+ question: 'How fast is delivery?',
46
+ answer: 'Most digital products are delivered instantly after payment confirmation.',
47
+ },
48
+ {
49
+ question: 'Which payment methods do you accept?',
50
+ answer: 'Available payment methods depend on your shop configuration and region.',
51
+ },
52
+ {
53
+ question: 'How do I get support?',
54
+ answer: 'Use the contact page or your customer portal for order-related help.',
55
+ },
56
+ ];
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createBlockInstance } from '@shoppex/builder-contracts';
3
+ import {
4
+ getBuilderBlockSettingText,
5
+ getLayoutPageBlockAttributes,
6
+ getProductPageBlockAttributes,
7
+ } from './product-page.js';
8
+
9
+ describe('product-page helpers', () => {
10
+ test('getBuilderBlockSettingText trims non-empty strings', () => {
11
+ const block = createBlockInstance({
12
+ type: 'buy-box',
13
+ settings: {
14
+ variantLabel: ' License Type ',
15
+ addonsLabel: ' ',
16
+ },
17
+ });
18
+
19
+ expect(getBuilderBlockSettingText(block, 'variantLabel')).toBe('License Type');
20
+ expect(getBuilderBlockSettingText(block, 'addonsLabel')).toBeNull();
21
+ expect(getBuilderBlockSettingText(block, 'missing')).toBeNull();
22
+ });
23
+
24
+ test('getLayoutPageBlockAttributes includes page id and builder block attrs', () => {
25
+ const block = createBlockInstance({ id: 'gallery-1', type: 'gallery' });
26
+ expect(getProductPageBlockAttributes(block)).toEqual({
27
+ 'data-page-id': 'product',
28
+ 'data-builder-block': 'gallery-1',
29
+ 'data-builder-block-type': 'gallery',
30
+ });
31
+ expect(getLayoutPageBlockAttributes('reviews-page', block)).toEqual({
32
+ 'data-page-id': 'reviews-page',
33
+ 'data-builder-block': 'gallery-1',
34
+ 'data-builder-block-type': 'gallery',
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,32 @@
1
+ import type { BlockInstance } from '@shoppex/builder-contracts';
2
+ import { builderBlock } from './attributes.js';
3
+
4
+ export function getBuilderBlockSettingText(
5
+ block: Pick<BlockInstance, 'settings'>,
6
+ key: string,
7
+ ): string | null {
8
+ const value = block.settings[key];
9
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
10
+ }
11
+
12
+ export function getLayoutPageBlockAttributes(
13
+ pageId: string,
14
+ block: Pick<BlockInstance, 'id' | 'type'>,
15
+ ) {
16
+ return {
17
+ 'data-page-id': pageId,
18
+ ...builderBlock(block.id, block.type),
19
+ };
20
+ }
21
+
22
+ export function getProductPageBlockAttributes(
23
+ block: Pick<BlockInstance, 'id' | 'type'>,
24
+ ) {
25
+ return getLayoutPageBlockAttributes('product', block);
26
+ }
27
+
28
+ /** @deprecated Use getBuilderBlockSettingText */
29
+ export const getProductBlockText = getBuilderBlockSettingText;
30
+
31
+ /** @deprecated Use getProductPageBlockAttributes */
32
+ export const getBuilderProductBlockAttributes = getProductPageBlockAttributes;
@@ -8,8 +8,11 @@ import {
8
8
  BuilderBlockProvider,
9
9
  BuilderPage,
10
10
  BuilderRuntimePreviewProvider,
11
+ resolvePreviewReloadTarget,
11
12
  useBuilderContent,
12
13
  useBuilderContentRecord,
14
+ useThemePageBlocks,
15
+ useThemePageBlockAttributes,
13
16
  } from './react.js';
14
17
 
15
18
  function createSettings(revision: number, title: string): BuilderSettings {
@@ -96,6 +99,33 @@ function ScopedProbeContent() {
96
99
  );
97
100
  }
98
101
 
102
+ function PageBlocksProbe({ pageId = 'home', defaultOrder = ['hero', 'products'] }: {
103
+ pageId?: string;
104
+ defaultOrder?: string[];
105
+ }) {
106
+ const blocks = useThemePageBlocks(pageId, defaultOrder);
107
+
108
+ return (
109
+ <ol data-testid="page-blocks">
110
+ {blocks.map((block) => (
111
+ <li key={block.id}>{block.id}:{block.type}</li>
112
+ ))}
113
+ </ol>
114
+ );
115
+ }
116
+
117
+ function PageBlockAttributesProbe({
118
+ pageId = 'contact-page',
119
+ defaultOrder = ['page-contact-page'],
120
+ }: {
121
+ pageId?: string;
122
+ defaultOrder?: string[];
123
+ }) {
124
+ const attrs = useThemePageBlockAttributes(pageId, defaultOrder);
125
+
126
+ return <section data-testid="page-block-attrs" {...attrs} />;
127
+ }
128
+
99
129
  function HeroBlock({ block }: { block: BlockInstance }) {
100
130
  const title = useBuilderContent('hero.title', '');
101
131
 
@@ -106,6 +136,26 @@ function HeroBlock({ block }: { block: BlockInstance }) {
106
136
  );
107
137
  }
108
138
 
139
+ describe('resolvePreviewReloadTarget', () => {
140
+ test('reloads through the worker session route when one was injected', () => {
141
+ expect(
142
+ resolvePreviewReloadTarget(
143
+ { search: '?shoppex-preview-mode=theme', hash: '#hero' },
144
+ '/s/session-1/draft-1/products/example',
145
+ ),
146
+ ).toBe('/s/session-1/draft-1/products/example?shoppex-preview-mode=theme#hero');
147
+ });
148
+
149
+ test('keeps normal browser reload behavior outside preview sessions', () => {
150
+ expect(
151
+ resolvePreviewReloadTarget(
152
+ { search: '?shoppex-preview-mode=theme', hash: '' },
153
+ '/products/example',
154
+ ),
155
+ ).toBeNull();
156
+ });
157
+ });
158
+
109
159
  describe('BuilderRuntimePreviewProvider', () => {
110
160
  let dom: JSDOM;
111
161
  let root: Root;
@@ -184,10 +234,47 @@ describe('BuilderRuntimePreviewProvider', () => {
184
234
  );
185
235
  });
186
236
 
187
- expect(postedMessages).toContainEqual({ type: 'READY', revision: 3 });
237
+ expect(postedMessages).toContainEqual({
238
+ type: 'READY',
239
+ revision: 3,
240
+ health: {
241
+ reactMounted: true,
242
+ builderRuntimeProvider: true,
243
+ protocolVersion: 2,
244
+ },
245
+ });
188
246
  expect(dom.window.document.body.textContent).toContain('Initial title');
189
247
  });
190
248
 
249
+ test('sends READY when the trusted parent origin is provided explicitly without a referrer', async () => {
250
+ dom.reconfigure({
251
+ url: 'https://preview.shoppex.test/?shoppex-preview-mode=theme&shoppex-preview-parent-origin=https%3A%2F%2Fdashboard.shoppex.test',
252
+ });
253
+ Object.defineProperty(dom.window.document, 'referrer', {
254
+ configurable: true,
255
+ value: '',
256
+ });
257
+
258
+ await act(async () => {
259
+ root.render(
260
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(4, 'Explicit origin title')}>
261
+ <Probe />
262
+ </BuilderRuntimePreviewProvider>,
263
+ );
264
+ });
265
+
266
+ expect(postedMessages).toContainEqual({
267
+ type: 'READY',
268
+ revision: 4,
269
+ health: {
270
+ reactMounted: true,
271
+ builderRuntimeProvider: true,
272
+ protocolVersion: 2,
273
+ },
274
+ });
275
+ expect(dom.window.document.body.textContent).toContain('Explicit origin title');
276
+ });
277
+
191
278
  test('normalizes mixed initial builder settings before strict validation', async () => {
192
279
  await act(async () => {
193
280
  root.render(
@@ -222,7 +309,15 @@ describe('BuilderRuntimePreviewProvider', () => {
222
309
  );
223
310
  });
224
311
 
225
- expect(postedMessages).toContainEqual({ type: 'READY', revision: 9 });
312
+ expect(postedMessages).toContainEqual({
313
+ type: 'READY',
314
+ revision: 9,
315
+ health: {
316
+ reactMounted: true,
317
+ builderRuntimeProvider: true,
318
+ protocolVersion: 2,
319
+ },
320
+ });
226
321
  expect(dom.window.document.body.textContent).toContain('Mixed title');
227
322
  });
228
323
 
@@ -261,7 +356,39 @@ describe('BuilderRuntimePreviewProvider', () => {
261
356
  );
262
357
  });
263
358
 
264
- expect(postedMessages).toEqual([{ type: 'READY', revision: 3 }]);
359
+ expect(postedMessages).toEqual([{
360
+ type: 'READY',
361
+ revision: 3,
362
+ health: {
363
+ reactMounted: true,
364
+ builderRuntimeProvider: true,
365
+ protocolVersion: 2,
366
+ },
367
+ }]);
368
+ });
369
+
370
+ test('reports iframe runtime errors to the builder parent', async () => {
371
+ await act(async () => {
372
+ root.render(
373
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(6, 'Initial title')}>
374
+ <Probe />
375
+ </BuilderRuntimePreviewProvider>,
376
+ );
377
+ });
378
+
379
+ postedMessages.length = 0;
380
+
381
+ dom.window.dispatchEvent(new dom.window.ErrorEvent('error', {
382
+ error: new Error('Preview crashed'),
383
+ message: 'Preview crashed',
384
+ }));
385
+
386
+ expect(postedMessages).toEqual([expect.objectContaining({
387
+ type: 'PREVIEW_ERROR',
388
+ revision: 6,
389
+ message: 'Preview crashed',
390
+ source: 'error',
391
+ })]);
265
392
  });
266
393
 
267
394
  test('applies preview state and acknowledges the exact revision', async () => {
@@ -313,6 +440,91 @@ describe('BuilderRuntimePreviewProvider', () => {
313
440
  expect(dom.window.document.body.textContent).toContain('Registry title');
314
441
  });
315
442
 
443
+ test('returns visible page blocks or manifest default order from the runtime helper', async () => {
444
+ await act(async () => {
445
+ root.render(
446
+ <BuilderRuntimePreviewProvider initialSettings={createEmptyBuilderSettings(1)}>
447
+ <PageBlocksProbe />
448
+ </BuilderRuntimePreviewProvider>,
449
+ );
450
+ });
451
+
452
+ expect(dom.window.document.querySelector('[data-testid="page-blocks"]')?.textContent).toBe('hero:heroproducts:products');
453
+
454
+ await act(async () => {
455
+ dom.window.dispatchEvent(
456
+ new dom.window.MessageEvent('message', {
457
+ origin: 'https://dashboard.shoppex.test',
458
+ source: parentWindow as Window,
459
+ data: {
460
+ type: 'APPLY_STATE',
461
+ revision: 2,
462
+ state: {
463
+ ...createEmptyBuilderSettings(2),
464
+ theme: {
465
+ ...createEmptyBuilderSettings(2).theme,
466
+ layout: {
467
+ home: {
468
+ blocks: [
469
+ { id: 'hero-1', type: 'hero', visible: true, settings: {} },
470
+ { id: 'faq-1', type: 'faq', visible: false, settings: {} },
471
+ ],
472
+ },
473
+ },
474
+ },
475
+ },
476
+ },
477
+ }),
478
+ );
479
+ });
480
+
481
+ expect(dom.window.document.querySelector('[data-testid="page-blocks"]')?.textContent).toBe('hero-1:hero');
482
+ });
483
+
484
+ test('useThemePageBlockAttributes exposes page id and first block attrs', async () => {
485
+ await act(async () => {
486
+ root.render(
487
+ <BuilderRuntimePreviewProvider
488
+ initialSettings={{
489
+ ...createEmptyBuilderSettings(1),
490
+ theme: {
491
+ ...createEmptyBuilderSettings(1).theme,
492
+ layout: {
493
+ 'contact-page': {
494
+ blocks: [
495
+ { id: 'contact-1', type: 'page-contact-page', visible: true, settings: {} },
496
+ ],
497
+ },
498
+ },
499
+ },
500
+ }}
501
+ >
502
+ <PageBlockAttributesProbe />
503
+ </BuilderRuntimePreviewProvider>,
504
+ );
505
+ });
506
+
507
+ const section = dom.window.document.querySelector('[data-testid="page-block-attrs"]');
508
+ expect(section?.getAttribute('data-page-id')).toBe('contact-page');
509
+ expect(section?.getAttribute('data-builder-block')).toBe('contact-1');
510
+ expect(section?.getAttribute('data-builder-block-type')).toBe('page-contact-page');
511
+ });
512
+
513
+ test('shows missing registry blocks inside trusted builder preview', async () => {
514
+ await act(async () => {
515
+ root.render(
516
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(3, 'Registry title')}>
517
+ <BuilderPage pageId="home" registry={{}} context={{}} />
518
+ </BuilderRuntimePreviewProvider>,
519
+ );
520
+ });
521
+
522
+ const missingBlock = dom.window.document.querySelector('[data-builder-runtime-error="missing-block-component"]');
523
+
524
+ expect(missingBlock?.getAttribute('data-builder-block')).toBe('hero-1');
525
+ expect(missingBlock?.textContent).toContain('Missing Builder component for block "hero".');
526
+ });
527
+
316
528
  test('prefers scoped block settings over global builder content inside a block provider', async () => {
317
529
  await act(async () => {
318
530
  root.render(