@shoppexio/builder-runtime 0.1.2 → 0.1.4
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.
- package/dist/YouTubeEmbed.d.ts +13 -0
- package/dist/YouTubeEmbed.d.ts.map +1 -0
- package/dist/YouTubeEmbed.js +49 -0
- package/dist/YouTubeEmbedBuilderBlock.d.ts +7 -0
- package/dist/YouTubeEmbedBuilderBlock.d.ts.map +1 -0
- package/dist/YouTubeEmbedBuilderBlock.js +16 -0
- package/dist/block-style-settings.d.ts +5 -0
- package/dist/block-style-settings.d.ts.map +1 -0
- package/dist/block-style-settings.js +16 -0
- package/dist/builder-runtime.test.d.ts +2 -0
- package/dist/builder-runtime.test.d.ts.map +1 -0
- package/dist/builder-runtime.test.js +115 -0
- package/dist/content.d.ts +6 -0
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +31 -7
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/manifest-setting-paths.d.ts +5 -0
- package/dist/manifest-setting-paths.d.ts.map +1 -0
- package/dist/manifest-setting-paths.js +40 -0
- package/dist/merchant-custom-page.d.ts +57 -0
- package/dist/merchant-custom-page.d.ts.map +1 -0
- package/dist/merchant-custom-page.js +63 -0
- package/dist/preview-mode.d.ts +2 -0
- package/dist/preview-mode.d.ts.map +1 -0
- package/dist/preview-mode.js +7 -0
- package/dist/react-runtime.test.d.ts +2 -0
- package/dist/react-runtime.test.d.ts.map +1 -0
- package/dist/react-runtime.test.js +332 -0
- package/dist/react.d.ts +6 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +16 -4
- package/dist/youtube-embed-block.d.ts +10 -0
- package/dist/youtube-embed-block.d.ts.map +1 -0
- package/dist/youtube-embed-block.js +19 -0
- package/dist/youtube.d.ts +5 -0
- package/dist/youtube.d.ts.map +1 -0
- package/dist/youtube.js +52 -0
- package/package.json +1 -1
- package/src/YouTubeEmbed.tsx +105 -0
- package/src/YouTubeEmbedBuilderBlock.tsx +49 -0
- package/src/block-style-settings.ts +24 -0
- package/src/builder-runtime.test.ts +36 -0
- package/src/content.ts +44 -9
- package/src/index.ts +4 -0
- package/src/manifest-setting-paths.test.ts +23 -0
- package/src/manifest-setting-paths.ts +55 -0
- package/src/merchant-custom-page.tsx +161 -0
- package/src/preview-mode.test.ts +22 -0
- package/src/preview-mode.ts +8 -0
- package/src/react.tsx +29 -4
- package/src/youtube-embed-block.test.ts +76 -0
- package/src/youtube-embed-block.ts +28 -0
- package/src/youtube-embed-builder-block.test.tsx +166 -0
- package/src/youtube.test.ts +48 -0
- package/src/youtube.ts +66 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { BlockInstance } from '@shoppex/builder-contracts';
|
|
2
|
+
import { builderBlock } from './attributes.js';
|
|
3
|
+
import { isBuilderPreviewMode } from './preview-mode.js';
|
|
4
|
+
import { YouTubeEmbed, YouTubeEmbedPreviewPlaceholder } from './YouTubeEmbed.js';
|
|
5
|
+
import {
|
|
6
|
+
getYouTubeEmbedBlockStyleProps,
|
|
7
|
+
readYouTubeEmbedBlockSettings,
|
|
8
|
+
} from './youtube-embed-block.js';
|
|
9
|
+
import { parseYouTubeVideoId } from './youtube.js';
|
|
10
|
+
|
|
11
|
+
export type YouTubeEmbedBuilderBlockProps = {
|
|
12
|
+
block: Pick<BlockInstance, 'id' | 'type' | 'settings'>;
|
|
13
|
+
pageId?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function YouTubeEmbedBuilderBlock({
|
|
17
|
+
block,
|
|
18
|
+
pageId = 'home',
|
|
19
|
+
}: YouTubeEmbedBuilderBlockProps) {
|
|
20
|
+
const settings = readYouTubeEmbedBlockSettings(block);
|
|
21
|
+
const videoId = parseYouTubeVideoId(settings.videoUrl);
|
|
22
|
+
const showPreviewPlaceholder = !videoId && isBuilderPreviewMode();
|
|
23
|
+
|
|
24
|
+
if (!videoId && !showPreviewPlaceholder) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const styleProps = getYouTubeEmbedBlockStyleProps(block);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
key={block.id}
|
|
33
|
+
data-page-id={pageId}
|
|
34
|
+
{...builderBlock(block.id, block.type)}
|
|
35
|
+
style={Object.keys(styleProps).length > 0 ? styleProps : undefined}
|
|
36
|
+
>
|
|
37
|
+
{videoId ? (
|
|
38
|
+
<YouTubeEmbed
|
|
39
|
+
videoUrl={settings.videoUrl}
|
|
40
|
+
title={settings.title}
|
|
41
|
+
height={settings.height}
|
|
42
|
+
privacyEnhanced={settings.privacyEnhanced}
|
|
43
|
+
/>
|
|
44
|
+
) : (
|
|
45
|
+
<YouTubeEmbedPreviewPlaceholder height={settings.height} />
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
|
|
3
|
+
type StyleBlockSettings = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export function readManifestStyleBlockProps(
|
|
6
|
+
settings: StyleBlockSettings,
|
|
7
|
+
): CSSProperties {
|
|
8
|
+
const bg = settings['style.background'];
|
|
9
|
+
const radius = settings['style.borderRadius'];
|
|
10
|
+
const padding = settings['style.padding'];
|
|
11
|
+
const style: CSSProperties = {};
|
|
12
|
+
|
|
13
|
+
if (typeof bg === 'string' && bg.length > 0) {
|
|
14
|
+
style.backgroundColor = bg;
|
|
15
|
+
}
|
|
16
|
+
if (typeof radius === 'number') {
|
|
17
|
+
style.borderRadius = `${radius}px`;
|
|
18
|
+
}
|
|
19
|
+
if (typeof padding === 'number') {
|
|
20
|
+
style.padding = `${padding}px`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return style;
|
|
24
|
+
}
|
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
createBuilderCss,
|
|
9
9
|
getBuilderContentList,
|
|
10
10
|
getBuilderContentString,
|
|
11
|
+
normalizeGalleryItems,
|
|
11
12
|
getPageBlocks,
|
|
13
|
+
getBlockSettingValue,
|
|
12
14
|
getThemePageBlockOrderFromManifest,
|
|
13
15
|
resolveBlockSettings,
|
|
14
16
|
resolveStyleSlotValue,
|
|
@@ -90,6 +92,19 @@ describe('@shoppex/builder-runtime', () => {
|
|
|
90
92
|
expect(getBuilderContentString(settings, 'hero.subtitle', 'Default subtitle')).toBe('');
|
|
91
93
|
});
|
|
92
94
|
|
|
95
|
+
test('normalizes gallery list items from image or url keys', () => {
|
|
96
|
+
expect(
|
|
97
|
+
normalizeGalleryItems([
|
|
98
|
+
{ image: '/media/a.png', alt: 'A' },
|
|
99
|
+
{ url: '/media/b.png' },
|
|
100
|
+
{ image: '' },
|
|
101
|
+
]),
|
|
102
|
+
).toEqual([
|
|
103
|
+
{ url: '/media/a.png', alt: 'A' },
|
|
104
|
+
{ url: '/media/b.png' },
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
93
108
|
test('resolves page blocks', () => {
|
|
94
109
|
const settings = createSettings();
|
|
95
110
|
|
|
@@ -186,6 +201,27 @@ describe('@shoppex/builder-runtime', () => {
|
|
|
186
201
|
expect(resolveBlockSettings(block, manifest)).toEqual({ title: 'Default hero' });
|
|
187
202
|
});
|
|
188
203
|
|
|
204
|
+
test('reads block settings from snake_cased storefront manifest keys', () => {
|
|
205
|
+
const settings = createSettings();
|
|
206
|
+
settings.theme.layout.home.blocks.push(
|
|
207
|
+
createBlockInstance({
|
|
208
|
+
id: 'how-it-works-1',
|
|
209
|
+
type: 'how-it-works',
|
|
210
|
+
settings: {
|
|
211
|
+
'how_it_works.before_image': '/media/before.png',
|
|
212
|
+
'how_it_works.after_image': '/media/after.png',
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(getBlockSettingValue(settings, {
|
|
218
|
+
pageId: 'home',
|
|
219
|
+
blockId: 'how-it-works-1',
|
|
220
|
+
path: 'howItWorks.beforeImage',
|
|
221
|
+
fallback: '',
|
|
222
|
+
})).toBe('/media/before.png');
|
|
223
|
+
});
|
|
224
|
+
|
|
189
225
|
test('creates the three supported builder attributes', () => {
|
|
190
226
|
expect(builderContent('hero.title')).toEqual({ 'data-builder-content': 'hero.title' });
|
|
191
227
|
expect(builderSlot('button.radius', { blockId: 'hero-1' })).toEqual({
|
package/src/content.ts
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import type { BuilderSettings } from '@shoppex/builder-contracts';
|
|
2
|
+
import { resolveManifestSettingRecordValue } from './manifest-setting-paths.js';
|
|
2
3
|
|
|
3
4
|
export type JsonRecord = Record<string, unknown>;
|
|
4
5
|
|
|
5
6
|
export function getBuilderContentValue(settings: BuilderSettings, path: string): unknown {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (Object.prototype.hasOwnProperty.call(content, path)) {
|
|
9
|
-
return content[path];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
return getByDottedPath(content, path);
|
|
7
|
+
return resolveManifestSettingRecordValue(settings.theme.content, path);
|
|
13
8
|
}
|
|
14
9
|
|
|
15
10
|
export function getBuilderContentString(settings: BuilderSettings, path: string, fallback?: string): string | undefined {
|
|
@@ -22,6 +17,45 @@ export function getBuilderContentList<T = unknown>(settings: BuilderSettings, pa
|
|
|
22
17
|
return Array.isArray(value) ? (value as T[]) : fallback;
|
|
23
18
|
}
|
|
24
19
|
|
|
20
|
+
export type NormalizedGalleryItem = {
|
|
21
|
+
url: string;
|
|
22
|
+
alt?: string;
|
|
23
|
+
caption?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function normalizeGalleryItems(raw: unknown): NormalizedGalleryItem[] {
|
|
27
|
+
if (!Array.isArray(raw)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const items: NormalizedGalleryItem[] = [];
|
|
32
|
+
for (const entry of raw) {
|
|
33
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const record = entry as Record<string, unknown>;
|
|
38
|
+
const url =
|
|
39
|
+
typeof record.url === 'string' && record.url.length > 0
|
|
40
|
+
? record.url
|
|
41
|
+
: typeof record.image === 'string' && record.image.length > 0
|
|
42
|
+
? record.image
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
if (!url) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
items.push({
|
|
50
|
+
url,
|
|
51
|
+
alt: typeof record.alt === 'string' ? record.alt : undefined,
|
|
52
|
+
caption: typeof record.caption === 'string' ? record.caption : undefined,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return items;
|
|
57
|
+
}
|
|
58
|
+
|
|
25
59
|
export function getBuilderContentRecord(settings: BuilderSettings): JsonRecord {
|
|
26
60
|
return settings.theme.content;
|
|
27
61
|
}
|
|
@@ -35,8 +69,9 @@ export function getBlockSettingValue<T = unknown>(
|
|
|
35
69
|
return input.fallback;
|
|
36
70
|
}
|
|
37
71
|
|
|
38
|
-
|
|
39
|
-
|
|
72
|
+
const resolved = resolveManifestSettingRecordValue(block.settings, input.path);
|
|
73
|
+
if (resolved !== undefined) {
|
|
74
|
+
return resolved as T;
|
|
40
75
|
}
|
|
41
76
|
|
|
42
77
|
const nested = getByDottedPath(block.settings, input.path);
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from './manifest-setting-paths.js';
|
|
1
2
|
export * from './preview-fixtures.js';
|
|
2
3
|
export * from './product-page.js';
|
|
3
4
|
export * from './standard-product-page.js';
|
|
@@ -8,3 +9,6 @@ export * from './layout.js';
|
|
|
8
9
|
export * from './react.js';
|
|
9
10
|
export * from './storefront-google-fonts.js';
|
|
10
11
|
export * from './style-slots.js';
|
|
12
|
+
export * from './block-style-settings.js';
|
|
13
|
+
export * from './preview-mode.js';
|
|
14
|
+
export * from './youtube.js';
|
|
@@ -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,22 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { isBuilderPreviewMode } from './preview-mode.js';
|
|
3
|
+
|
|
4
|
+
describe('isBuilderPreviewMode', () => {
|
|
5
|
+
const previousWindow = globalThis.window;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
globalThis.window = previousWindow;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('returns false during SSR without touching window.location', () => {
|
|
12
|
+
delete (globalThis as { window?: Window }).window;
|
|
13
|
+
|
|
14
|
+
expect(isBuilderPreviewMode()).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('reads the provided location search params', () => {
|
|
18
|
+
expect(isBuilderPreviewMode({ search: '?shoppex-preview-mode=builder' })).toBe(true);
|
|
19
|
+
expect(isBuilderPreviewMode({ search: '?shoppex-preview-mode=theme' })).toBe(true);
|
|
20
|
+
expect(isBuilderPreviewMode({ search: '?shoppex-preview-mode=live' })).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function isBuilderPreviewMode(location?: Pick<Location, 'search'>): boolean {
|
|
2
|
+
if (typeof window === 'undefined') {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const mode = new URLSearchParams((location ?? window.location).search).get('shoppex-preview-mode');
|
|
7
|
+
return mode === 'theme' || mode === 'builder';
|
|
8
|
+
}
|
package/src/react.tsx
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
getBuilderContentString,
|
|
28
28
|
getBuilderContentValue,
|
|
29
29
|
} from './content.js';
|
|
30
|
+
import { resolveManifestSettingRecordValue } from './manifest-setting-paths.js';
|
|
30
31
|
import { BUILDER_PREVIEW_REVIEWS } from './preview-fixtures.js';
|
|
31
32
|
import { createBuilderCss } from './css-vars.js';
|
|
32
33
|
import { builderBlock, type BuilderAttributeMap } from './attributes.js';
|
|
@@ -542,13 +543,17 @@ function useScopedBuilderContentValue(path: string): unknown {
|
|
|
542
543
|
const block = useContext(BuilderBlockContext);
|
|
543
544
|
if (!block) return undefined;
|
|
544
545
|
|
|
545
|
-
|
|
546
|
-
|
|
546
|
+
const resolved = resolveManifestSettingRecordValue(block.settings, path);
|
|
547
|
+
if (resolved !== undefined) {
|
|
548
|
+
return resolved;
|
|
547
549
|
}
|
|
548
550
|
|
|
549
551
|
const shortPath = path.startsWith(`${block.type}.`) ? path.slice(block.type.length + 1) : path.split('.').at(-1);
|
|
550
|
-
if (shortPath
|
|
551
|
-
|
|
552
|
+
if (shortPath) {
|
|
553
|
+
const shortResolved = resolveManifestSettingRecordValue(block.settings, shortPath);
|
|
554
|
+
if (shortResolved !== undefined) {
|
|
555
|
+
return shortResolved;
|
|
556
|
+
}
|
|
552
557
|
}
|
|
553
558
|
|
|
554
559
|
const nested = shortPath ? getNestedBuilderSetting(block.settings, shortPath) : undefined;
|
|
@@ -903,6 +908,7 @@ function installBuilderPreviewHoverInspector(): () => void {
|
|
|
903
908
|
// we keep a small override table for these.
|
|
904
909
|
const SLUG_LABEL_OVERRIDES: Record<string, string> = {
|
|
905
910
|
'custom-html': 'Custom Embed',
|
|
911
|
+
'youtube-embed': 'YouTube Video',
|
|
906
912
|
};
|
|
907
913
|
const override = SLUG_LABEL_OVERRIDES[type];
|
|
908
914
|
const label = override ?? type
|
|
@@ -1271,4 +1277,23 @@ export type {
|
|
|
1271
1277
|
StandardProductSettingScope,
|
|
1272
1278
|
StandardProductTabSpec,
|
|
1273
1279
|
} from './standard-product-blocks.js';
|
|
1280
|
+
export { YouTubeEmbed, YouTubeEmbedPreviewPlaceholder, type YouTubeEmbedProps } from './YouTubeEmbed.js';
|
|
1281
|
+
export {
|
|
1282
|
+
YouTubeEmbedBuilderBlock,
|
|
1283
|
+
type YouTubeEmbedBuilderBlockProps,
|
|
1284
|
+
} from './YouTubeEmbedBuilderBlock.js';
|
|
1285
|
+
export {
|
|
1286
|
+
createMerchantCustomPageRegistry,
|
|
1287
|
+
MerchantCustomPageBuilderView,
|
|
1288
|
+
useMerchantCustomPageRegistry,
|
|
1289
|
+
useMerchantCustomPageView,
|
|
1290
|
+
type MerchantCustomPageRegistryOptions,
|
|
1291
|
+
} from './merchant-custom-page.js';
|
|
1292
|
+
export { isBuilderPreviewMode } from './preview-mode.js';
|
|
1293
|
+
export {
|
|
1294
|
+
getYouTubeEmbedBlockStyleProps,
|
|
1295
|
+
readYouTubeEmbedBlockSettings,
|
|
1296
|
+
type YouTubeEmbedBlockInstance,
|
|
1297
|
+
} from './youtube-embed-block.js';
|
|
1298
|
+
export { readManifestStyleBlockProps } from './block-style-settings.js';
|
|
1274
1299
|
export { getBuilderPreviewReviewFixtures } from './preview-fixtures.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readManifestStyleBlockProps } from './block-style-settings.js';
|
|
3
|
+
import {
|
|
4
|
+
getYouTubeEmbedBlockStyleProps,
|
|
5
|
+
readYouTubeEmbedBlockSettings,
|
|
6
|
+
} from './youtube-embed-block.js';
|
|
7
|
+
|
|
8
|
+
describe('readYouTubeEmbedBlockSettings', () => {
|
|
9
|
+
test('reads url, title, height, and privacy flag from block settings', () => {
|
|
10
|
+
expect(
|
|
11
|
+
readYouTubeEmbedBlockSettings({
|
|
12
|
+
id: 'yt-1',
|
|
13
|
+
settings: {
|
|
14
|
+
videoUrl: 'https://youtu.be/dQw4w9WgXcQ',
|
|
15
|
+
title: 'Launch trailer',
|
|
16
|
+
height: 480,
|
|
17
|
+
privacyEnhanced: true,
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
).toEqual({
|
|
21
|
+
videoUrl: 'https://youtu.be/dQw4w9WgXcQ',
|
|
22
|
+
title: 'Launch trailer',
|
|
23
|
+
height: 480,
|
|
24
|
+
privacyEnhanced: true,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('falls back to defaults for missing or invalid values', () => {
|
|
29
|
+
expect(
|
|
30
|
+
readYouTubeEmbedBlockSettings({
|
|
31
|
+
id: 'yt-1',
|
|
32
|
+
settings: {
|
|
33
|
+
videoUrl: 42,
|
|
34
|
+
title: ' ',
|
|
35
|
+
height: 0,
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
).toEqual({
|
|
39
|
+
videoUrl: '',
|
|
40
|
+
title: 'YouTube video',
|
|
41
|
+
height: undefined,
|
|
42
|
+
privacyEnhanced: false,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('readManifestStyleBlockProps', () => {
|
|
48
|
+
test('maps manifest style settings to inline styles', () => {
|
|
49
|
+
expect(
|
|
50
|
+
readManifestStyleBlockProps({
|
|
51
|
+
'style.background': '#111111',
|
|
52
|
+
'style.borderRadius': 12,
|
|
53
|
+
'style.padding': 16,
|
|
54
|
+
}),
|
|
55
|
+
).toEqual({
|
|
56
|
+
backgroundColor: '#111111',
|
|
57
|
+
borderRadius: '12px',
|
|
58
|
+
padding: '16px',
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('getYouTubeEmbedBlockStyleProps', () => {
|
|
64
|
+
test('delegates to shared manifest style reader', () => {
|
|
65
|
+
expect(
|
|
66
|
+
getYouTubeEmbedBlockStyleProps({
|
|
67
|
+
id: 'yt-1',
|
|
68
|
+
settings: {
|
|
69
|
+
'style.background': '#00000000',
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
).toEqual({
|
|
73
|
+
backgroundColor: '#00000000',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { BlockInstance } from '@shoppex/builder-contracts';
|
|
2
|
+
import { readManifestStyleBlockProps } from './block-style-settings.js';
|
|
3
|
+
|
|
4
|
+
export type YouTubeEmbedBlockInstance = Pick<BlockInstance, 'id' | 'settings'>;
|
|
5
|
+
|
|
6
|
+
export function readYouTubeEmbedBlockSettings(block: YouTubeEmbedBlockInstance) {
|
|
7
|
+
const videoUrl =
|
|
8
|
+
typeof block.settings.videoUrl === 'string' ? block.settings.videoUrl : '';
|
|
9
|
+
const title =
|
|
10
|
+
typeof block.settings.title === 'string' && block.settings.title.trim().length > 0
|
|
11
|
+
? block.settings.title.trim()
|
|
12
|
+
: 'YouTube video';
|
|
13
|
+
const heightRaw = block.settings.height;
|
|
14
|
+
const height =
|
|
15
|
+
typeof heightRaw === 'number' && heightRaw > 0 ? heightRaw : undefined;
|
|
16
|
+
const privacyEnhanced = block.settings.privacyEnhanced === true;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
videoUrl,
|
|
20
|
+
title,
|
|
21
|
+
height,
|
|
22
|
+
privacyEnhanced,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getYouTubeEmbedBlockStyleProps(block: YouTubeEmbedBlockInstance) {
|
|
27
|
+
return readManifestStyleBlockProps(block.settings);
|
|
28
|
+
}
|