@shoppexio/builder-runtime 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/YouTubeEmbed.d.ts +13 -0
  2. package/dist/YouTubeEmbed.d.ts.map +1 -0
  3. package/dist/YouTubeEmbed.js +49 -0
  4. package/dist/YouTubeEmbedBuilderBlock.d.ts +7 -0
  5. package/dist/YouTubeEmbedBuilderBlock.d.ts.map +1 -0
  6. package/dist/YouTubeEmbedBuilderBlock.js +16 -0
  7. package/dist/block-style-settings.d.ts +5 -0
  8. package/dist/block-style-settings.d.ts.map +1 -0
  9. package/dist/block-style-settings.js +16 -0
  10. package/dist/builder-runtime.test.d.ts +2 -0
  11. package/dist/builder-runtime.test.d.ts.map +1 -0
  12. package/dist/builder-runtime.test.js +115 -0
  13. package/dist/content.d.ts +6 -0
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +31 -7
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +8 -0
  19. package/dist/layout.d.ts +3 -8
  20. package/dist/layout.d.ts.map +1 -1
  21. package/dist/layout.js +2 -10
  22. package/dist/manifest-setting-paths.d.ts +5 -0
  23. package/dist/manifest-setting-paths.d.ts.map +1 -0
  24. package/dist/manifest-setting-paths.js +40 -0
  25. package/dist/merchant-custom-page.d.ts +57 -0
  26. package/dist/merchant-custom-page.d.ts.map +1 -0
  27. package/dist/merchant-custom-page.js +63 -0
  28. package/dist/preview-fixtures.d.ts +16 -0
  29. package/dist/preview-fixtures.d.ts.map +1 -0
  30. package/dist/preview-fixtures.js +40 -0
  31. package/dist/preview-mode.d.ts +2 -0
  32. package/dist/preview-mode.d.ts.map +1 -0
  33. package/dist/preview-mode.js +7 -0
  34. package/dist/product-page.d.ts +13 -0
  35. package/dist/product-page.d.ts.map +1 -0
  36. package/dist/product-page.js +18 -0
  37. package/dist/react-runtime.test.d.ts +2 -0
  38. package/dist/react-runtime.test.d.ts.map +1 -0
  39. package/dist/react-runtime.test.js +332 -0
  40. package/dist/react.d.ts +37 -2
  41. package/dist/react.d.ts.map +1 -1
  42. package/dist/react.js +138 -46
  43. package/dist/search-bar-settings.d.ts +33 -0
  44. package/dist/search-bar-settings.d.ts.map +1 -0
  45. package/dist/search-bar-settings.js +99 -0
  46. package/dist/standard-product-blocks.d.ts +48 -0
  47. package/dist/standard-product-blocks.d.ts.map +1 -0
  48. package/dist/standard-product-blocks.js +45 -0
  49. package/dist/standard-product-page.d.ts +69 -0
  50. package/dist/standard-product-page.d.ts.map +1 -0
  51. package/dist/standard-product-page.js +89 -0
  52. package/dist/storefront-google-fonts.d.ts +2 -0
  53. package/dist/storefront-google-fonts.d.ts.map +1 -0
  54. package/dist/storefront-google-fonts.js +28 -0
  55. package/dist/youtube-embed-block.d.ts +10 -0
  56. package/dist/youtube-embed-block.d.ts.map +1 -0
  57. package/dist/youtube-embed-block.js +19 -0
  58. package/dist/youtube.d.ts +5 -0
  59. package/dist/youtube.d.ts.map +1 -0
  60. package/dist/youtube.js +52 -0
  61. package/package.json +3 -3
  62. package/src/YouTubeEmbed.tsx +105 -0
  63. package/src/YouTubeEmbedBuilderBlock.tsx +49 -0
  64. package/src/block-style-settings.ts +24 -0
  65. package/src/builder-runtime.test.ts +69 -0
  66. package/src/content.ts +44 -9
  67. package/src/index.ts +8 -0
  68. package/src/layout.ts +11 -21
  69. package/src/manifest-setting-paths.test.ts +23 -0
  70. package/src/manifest-setting-paths.ts +55 -0
  71. package/src/merchant-custom-page.tsx +161 -0
  72. package/src/preview-fixtures.ts +56 -0
  73. package/src/preview-mode.ts +8 -0
  74. package/src/product-page.test.ts +37 -0
  75. package/src/product-page.ts +32 -0
  76. package/src/react-runtime.test.tsx +42 -0
  77. package/src/react.tsx +243 -49
  78. package/src/search-bar-settings.test.ts +72 -0
  79. package/src/search-bar-settings.ts +176 -0
  80. package/src/standard-product-blocks.test.tsx +93 -0
  81. package/src/standard-product-blocks.tsx +121 -0
  82. package/src/standard-product-page.test.ts +171 -0
  83. package/src/standard-product-page.ts +169 -0
  84. package/src/storefront-google-fonts.test.ts +31 -0
  85. package/src/storefront-google-fonts.ts +43 -0
  86. package/src/youtube-embed-block.test.ts +76 -0
  87. package/src/youtube-embed-block.ts +28 -0
  88. package/src/youtube-embed-builder-block.test.tsx +166 -0
  89. package/src/youtube.test.ts +48 -0
  90. package/src/youtube.ts +66 -0
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ resolveManifestSettingRecordValue,
4
+ toSnakeCaseManifestPath,
5
+ } from './manifest-setting-paths.js';
6
+
7
+ describe('manifest-setting-paths', () => {
8
+ it('converts dotted manifest paths to snake_case aliases', () => {
9
+ expect(toSnakeCaseManifestPath('howItWorks.beforeImage')).toBe('how_it_works.before_image');
10
+ expect(toSnakeCaseManifestPath('theme.accentColor')).toBe('theme.accent_color');
11
+ expect(toSnakeCaseManifestPath('hero.image')).toBe('hero.image');
12
+ });
13
+
14
+ it('reads canonical and snake_cased block setting keys', () => {
15
+ const record = {
16
+ 'howItWorks.beforeImage': '/media/before.png',
17
+ 'how_it_works.after_image': '/media/after.png',
18
+ };
19
+
20
+ expect(resolveManifestSettingRecordValue(record, 'howItWorks.beforeImage')).toBe('/media/before.png');
21
+ expect(resolveManifestSettingRecordValue(record, 'howItWorks.afterImage')).toBe('/media/after.png');
22
+ });
23
+ });
@@ -0,0 +1,55 @@
1
+ import type { JsonRecord } from './content.js';
2
+
3
+ export function toSnakeCaseManifestSegment(value: string): string {
4
+ return value
5
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
6
+ .replace(/[\s-]+/g, '_')
7
+ .toLowerCase();
8
+ }
9
+
10
+ export function toSnakeCaseManifestPath(path: string): string {
11
+ return path
12
+ .split('.')
13
+ .filter(Boolean)
14
+ .map(toSnakeCaseManifestSegment)
15
+ .join('.');
16
+ }
17
+
18
+ function getByDottedPath(record: JsonRecord, path: string): unknown {
19
+ let current: unknown = record;
20
+
21
+ for (const segment of path.split('.')) {
22
+ if (!current || typeof current !== 'object') {
23
+ return undefined;
24
+ }
25
+
26
+ current = (current as JsonRecord)[segment];
27
+ }
28
+
29
+ return current;
30
+ }
31
+
32
+ export function resolveManifestSettingRecordValue(
33
+ record: JsonRecord,
34
+ path: string,
35
+ ): unknown {
36
+ if (Object.prototype.hasOwnProperty.call(record, path)) {
37
+ return record[path];
38
+ }
39
+
40
+ const snakePath = toSnakeCaseManifestPath(path);
41
+ if (snakePath !== path && Object.prototype.hasOwnProperty.call(record, snakePath)) {
42
+ return record[snakePath];
43
+ }
44
+
45
+ const nested = getByDottedPath(record, path);
46
+ if (nested !== undefined) {
47
+ return nested;
48
+ }
49
+
50
+ if (snakePath !== path) {
51
+ return getByDottedPath(record, snakePath);
52
+ }
53
+
54
+ return undefined;
55
+ }
@@ -0,0 +1,161 @@
1
+ 'use client';
2
+
3
+ import type { BlockInstance } from '@shoppex/builder-contracts';
4
+ import { findCustomPageBySlug } from '@shoppex/builder-contracts';
5
+ import type { ComponentType, ReactNode } from 'react';
6
+ import { useMemo } from 'react';
7
+ import { builderBlock } from './attributes.js';
8
+ import { readManifestStyleBlockProps } from './block-style-settings.js';
9
+ import { BuilderPage, useBuilderRuntime, useThemePageBlocks, type BuilderBlockRegistry } from './react.js';
10
+ import { YouTubeEmbedBuilderBlock } from './YouTubeEmbedBuilderBlock.js';
11
+
12
+ type CustomEmbedProps = {
13
+ embedHtml?: string;
14
+ height?: number;
15
+ autoResize?: boolean;
16
+ width?: 'full' | 'boxed' | 'embed';
17
+ };
18
+
19
+ type TextBlockProps = {
20
+ eyebrow?: string;
21
+ title?: string;
22
+ body?: string;
23
+ alignment?: 'left' | 'center' | 'right';
24
+ ctaLabel?: string;
25
+ ctaHref?: string;
26
+ styleBackground?: string;
27
+ styleAccentColor?: string;
28
+ };
29
+
30
+ export type MerchantCustomPageRegistryOptions = {
31
+ pageId: string;
32
+ CustomEmbed: ComponentType<CustomEmbedProps>;
33
+ TextBlock?: ComponentType<TextBlockProps>;
34
+ };
35
+
36
+ function readStringSetting(block: BlockInstance, key: string): string | undefined {
37
+ const value = block.settings[key];
38
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
39
+ }
40
+
41
+ function readNumberSetting(block: BlockInstance, key: string): number | undefined {
42
+ const value = block.settings[key];
43
+ return typeof value === 'number' && value > 0 ? value : undefined;
44
+ }
45
+
46
+ export function createMerchantCustomPageRegistry(
47
+ options: MerchantCustomPageRegistryOptions,
48
+ ): BuilderBlockRegistry {
49
+ const { pageId, CustomEmbed, TextBlock } = options;
50
+
51
+ const registry: BuilderBlockRegistry = {
52
+ 'youtube-embed': ({ block }) => (
53
+ <YouTubeEmbedBuilderBlock block={block} pageId={pageId} />
54
+ ),
55
+ 'custom-html': ({ block }) => {
56
+ const styleProps = readManifestStyleBlockProps(block.settings);
57
+ const width = block.settings.width;
58
+ return (
59
+ <div
60
+ key={block.id}
61
+ data-page-id={pageId}
62
+ {...builderBlock(block.id, block.type)}
63
+ style={Object.keys(styleProps).length > 0 ? styleProps : undefined}
64
+ >
65
+ <CustomEmbed
66
+ embedHtml={readStringSetting(block, 'embedHtml')}
67
+ height={readNumberSetting(block, 'height')}
68
+ autoResize={block.settings.autoResize === true}
69
+ width={
70
+ width === 'full' || width === 'boxed' || width === 'embed'
71
+ ? width
72
+ : 'embed'
73
+ }
74
+ />
75
+ </div>
76
+ );
77
+ },
78
+ };
79
+
80
+ if (TextBlock) {
81
+ registry['text-block'] = ({ block }) => (
82
+ <div key={block.id} data-page-id={pageId} {...builderBlock(block.id, block.type)}>
83
+ <TextBlock
84
+ eyebrow={readStringSetting(block, 'eyebrow')}
85
+ title={readStringSetting(block, 'title')}
86
+ body={readStringSetting(block, 'body')}
87
+ alignment={
88
+ (readStringSetting(block, 'alignment') as TextBlockProps['alignment']) ??
89
+ undefined
90
+ }
91
+ ctaLabel={readStringSetting(block, 'ctaLabel')}
92
+ ctaHref={readStringSetting(block, 'ctaHref')}
93
+ styleBackground={readStringSetting(block, 'style.background')}
94
+ styleAccentColor={readStringSetting(block, 'style.accentColor')}
95
+ />
96
+ </div>
97
+ );
98
+ }
99
+
100
+ return registry;
101
+ }
102
+
103
+ export function MerchantCustomPageBuilderView({
104
+ pageId,
105
+ registry,
106
+ className,
107
+ children,
108
+ }: {
109
+ pageId: string;
110
+ registry: BuilderBlockRegistry;
111
+ className?: string;
112
+ children?: ReactNode;
113
+ }) {
114
+ const blocks = useThemePageBlocks(pageId, []);
115
+
116
+ if (blocks.length === 0) {
117
+ return null;
118
+ }
119
+
120
+ return (
121
+ <div className={className}>
122
+ {children}
123
+ <BuilderPage pageId={pageId} blocks={blocks} registry={registry} context={null} />
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export function useMerchantCustomPageRegistry(
129
+ options: MerchantCustomPageRegistryOptions | null,
130
+ ): BuilderBlockRegistry | null {
131
+ return useMemo(
132
+ () => (options ? createMerchantCustomPageRegistry(options) : null),
133
+ [options?.pageId, options?.CustomEmbed, options?.TextBlock],
134
+ );
135
+ }
136
+
137
+ export function useMerchantCustomPageView(
138
+ slug: string | undefined,
139
+ components: Pick<MerchantCustomPageRegistryOptions, 'CustomEmbed' | 'TextBlock'>,
140
+ ) {
141
+ const { settings } = useBuilderRuntime();
142
+ const customPage = slug ? findCustomPageBySlug(settings, slug) : undefined;
143
+ const registry = useMerchantCustomPageRegistry(
144
+ customPage
145
+ ? {
146
+ pageId: customPage.id,
147
+ CustomEmbed: components.CustomEmbed,
148
+ TextBlock: components.TextBlock,
149
+ }
150
+ : null,
151
+ );
152
+ const builderBlockCount = customPage
153
+ ? (settings.theme.layout[customPage.id]?.blocks.length ?? 0)
154
+ : 0;
155
+
156
+ return {
157
+ customPage,
158
+ registry,
159
+ hasBuilderContent: builderBlockCount > 0,
160
+ };
161
+ }
@@ -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,8 @@
1
+ export function isBuilderPreviewMode(location: Pick<Location, 'search'> = window.location): boolean {
2
+ if (typeof window === 'undefined') {
3
+ return false;
4
+ }
5
+
6
+ const mode = new URLSearchParams(location.search).get('shoppex-preview-mode');
7
+ return mode === 'theme' || mode === 'builder';
8
+ }
@@ -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;
@@ -12,6 +12,7 @@ import {
12
12
  useBuilderContent,
13
13
  useBuilderContentRecord,
14
14
  useThemePageBlocks,
15
+ useThemePageBlockAttributes,
15
16
  } from './react.js';
16
17
 
17
18
  function createSettings(revision: number, title: string): BuilderSettings {
@@ -113,6 +114,18 @@ function PageBlocksProbe({ pageId = 'home', defaultOrder = ['hero', 'products']
113
114
  );
114
115
  }
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
+
116
129
  function HeroBlock({ block }: { block: BlockInstance }) {
117
130
  const title = useBuilderContent('hero.title', '');
118
131
 
@@ -468,6 +481,35 @@ describe('BuilderRuntimePreviewProvider', () => {
468
481
  expect(dom.window.document.querySelector('[data-testid="page-blocks"]')?.textContent).toBe('hero-1:hero');
469
482
  });
470
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
+
471
513
  test('shows missing registry blocks inside trusted builder preview', async () => {
472
514
  await act(async () => {
473
515
  root.render(