@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
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ buildSearchShellStyle,
4
+ resolveSearchBarSettings,
5
+ resolveSearchColor,
6
+ resolveSearchMaxWidth,
7
+ } from './search-bar-settings';
8
+
9
+ describe('resolveSearchBarSettings', () => {
10
+ test('hero variant prefers hero settings and falls back to navigation', () => {
11
+ const resolved = resolveSearchBarSettings({
12
+ variant: 'hero',
13
+ headerSettings: {
14
+ 'navigation.search.placeholder': 'Header placeholder',
15
+ 'navigation.search.background': '#111111',
16
+ },
17
+ heroValues: {
18
+ 'hero.search.placeholder': 'Hero placeholder',
19
+ 'hero.search.maxWidth': 'lg',
20
+ },
21
+ });
22
+
23
+ expect(resolved.placeholder).toBe('Hero placeholder');
24
+ expect(resolved.backgroundColor).toBe('#111111');
25
+ expect(resolved.maxWidth).toBe('36rem');
26
+ expect(resolved.showShortcut).toBe(true);
27
+ expect(resolved.showInHero).toBe(true);
28
+ });
29
+
30
+ test('navigation variant reads header settings only', () => {
31
+ const resolved = resolveSearchBarSettings({
32
+ variant: 'navigation',
33
+ headerSettings: {
34
+ 'navigation.search.placeholder': 'Find products',
35
+ 'navigation.search.borderRadius': 24,
36
+ },
37
+ heroValues: {
38
+ 'hero.search.placeholder': 'Ignored in navigation mode',
39
+ },
40
+ });
41
+
42
+ expect(resolved.placeholder).toBe('Find products');
43
+ expect(resolved.borderRadiusPx).toBe(24);
44
+ });
45
+ });
46
+
47
+ describe('search shell helpers', () => {
48
+ test('resolveSearchColor normalizes hex values', () => {
49
+ expect(resolveSearchColor('f97316')).toBe('#f97316');
50
+ expect(resolveSearchColor('not-a-color')).toBeUndefined();
51
+ });
52
+
53
+ test('resolveSearchMaxWidth maps preset keys', () => {
54
+ expect(resolveSearchMaxWidth('full')).toBe('100%');
55
+ });
56
+
57
+ test('buildSearchShellStyle returns only overridden properties', () => {
58
+ expect(
59
+ buildSearchShellStyle({
60
+ backgroundColor: '#101010',
61
+ borderColor: '#333333',
62
+ borderRadiusPx: 999,
63
+ }),
64
+ ).toEqual({
65
+ backgroundColor: '#101010',
66
+ borderColor: '#333333',
67
+ borderWidth: '1px',
68
+ borderStyle: 'solid',
69
+ borderRadius: '999px',
70
+ });
71
+ });
72
+ });
@@ -0,0 +1,176 @@
1
+ import type { BuilderSettings } from '@shoppex/builder-contracts';
2
+
3
+ export const SEARCH_BAR_MAX_WIDTH: Record<string, string> = {
4
+ sm: '20rem',
5
+ md: '28rem',
6
+ lg: '36rem',
7
+ full: '100%',
8
+ };
9
+
10
+ export type ResolvedSearchBarSettings = {
11
+ placeholder: string;
12
+ backgroundColor?: string;
13
+ borderColor?: string;
14
+ borderRadiusPx?: number;
15
+ maxWidth: string;
16
+ showShortcut: boolean;
17
+ showInHero: boolean;
18
+ };
19
+
20
+ export function getNavigationHeaderSettings(
21
+ settings: BuilderSettings,
22
+ ): Record<string, unknown> {
23
+ const block = settings.theme.layout.navigation?.blocks.find(
24
+ (candidate) => candidate.type === 'header',
25
+ );
26
+ return block?.settings ?? {};
27
+ }
28
+
29
+ export function readSetting(
30
+ sources: Array<Record<string, unknown> | undefined>,
31
+ path: string,
32
+ ): unknown {
33
+ for (const source of sources) {
34
+ if (!source) continue;
35
+ if (!Object.prototype.hasOwnProperty.call(source, path)) continue;
36
+ const value = source[path];
37
+ if (value !== undefined && value !== null && value !== '') {
38
+ return value;
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ export function isValidHexColor(value: unknown): value is string {
45
+ if (typeof value !== 'string' || !value.trim()) return false;
46
+ return /^#?[a-f\d]{3}(?:[a-f\d]{3})?(?:[a-f\d]{2})?$/i.test(value.trim());
47
+ }
48
+
49
+ export function normalizeHexColor(value: string): string {
50
+ const trimmed = value.trim();
51
+ return trimmed.startsWith('#') ? trimmed : `#${trimmed}`;
52
+ }
53
+
54
+ export function resolveSearchColor(value: unknown): string | undefined {
55
+ if (!isValidHexColor(value)) return undefined;
56
+ return normalizeHexColor(value);
57
+ }
58
+
59
+ export function resolveSearchBorderRadius(value: unknown): number | undefined {
60
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
61
+ return Math.max(0, Math.round(value));
62
+ }
63
+
64
+ export function resolveSearchMaxWidth(value: unknown, fallback = 'md'): string {
65
+ if (typeof value === 'string' && value in SEARCH_BAR_MAX_WIDTH) {
66
+ return SEARCH_BAR_MAX_WIDTH[value]!;
67
+ }
68
+ return SEARCH_BAR_MAX_WIDTH[fallback] ?? SEARCH_BAR_MAX_WIDTH.md!;
69
+ }
70
+
71
+ export function resolveSearchBoolean(value: unknown, fallback: boolean): boolean {
72
+ if (typeof value === 'boolean') return value;
73
+ return fallback;
74
+ }
75
+
76
+ export function resolveSearchPlaceholder(
77
+ value: unknown,
78
+ fallback: string,
79
+ ): string {
80
+ return typeof value === 'string' && value.trim() ? value : fallback;
81
+ }
82
+
83
+ export function resolveSearchBarSettings(input: {
84
+ variant: 'hero' | 'navigation';
85
+ heroValues: Record<string, unknown>;
86
+ headerSettings: Record<string, unknown>;
87
+ }): ResolvedSearchBarSettings {
88
+ const navSources = [input.headerSettings];
89
+ const heroSources = [input.heroValues, input.headerSettings];
90
+
91
+ if (input.variant === 'hero') {
92
+ return {
93
+ placeholder: resolveSearchPlaceholder(
94
+ readSetting(heroSources, 'hero.search.placeholder') ??
95
+ readSetting(navSources, 'navigation.search.placeholder'),
96
+ 'Search products…',
97
+ ),
98
+ backgroundColor: resolveSearchColor(
99
+ readSetting(heroSources, 'hero.search.background') ??
100
+ readSetting(navSources, 'navigation.search.background'),
101
+ ),
102
+ borderColor: resolveSearchColor(
103
+ readSetting(heroSources, 'hero.search.borderColor') ??
104
+ readSetting(navSources, 'navigation.search.borderColor'),
105
+ ),
106
+ borderRadiusPx: resolveSearchBorderRadius(
107
+ readSetting(heroSources, 'hero.search.borderRadius') ??
108
+ readSetting(navSources, 'navigation.search.borderRadius'),
109
+ ),
110
+ maxWidth: resolveSearchMaxWidth(
111
+ readSetting(heroSources, 'hero.search.maxWidth'),
112
+ 'md',
113
+ ),
114
+ showShortcut: resolveSearchBoolean(
115
+ readSetting(heroSources, 'hero.search.showShortcut'),
116
+ true,
117
+ ),
118
+ showInHero: resolveSearchBoolean(
119
+ readSetting(heroSources, 'hero.search.show'),
120
+ true,
121
+ ),
122
+ };
123
+ }
124
+
125
+ return {
126
+ placeholder: resolveSearchPlaceholder(
127
+ readSetting(navSources, 'navigation.search.placeholder'),
128
+ 'Search products…',
129
+ ),
130
+ backgroundColor: resolveSearchColor(
131
+ readSetting(navSources, 'navigation.search.background'),
132
+ ),
133
+ borderColor: resolveSearchColor(
134
+ readSetting(navSources, 'navigation.search.borderColor'),
135
+ ),
136
+ borderRadiusPx: resolveSearchBorderRadius(
137
+ readSetting(navSources, 'navigation.search.borderRadius'),
138
+ ),
139
+ maxWidth: SEARCH_BAR_MAX_WIDTH.md!,
140
+ showShortcut: true,
141
+ showInHero: true,
142
+ };
143
+ }
144
+
145
+ export function buildSearchShellStyle(
146
+ settings: Pick<
147
+ ResolvedSearchBarSettings,
148
+ 'backgroundColor' | 'borderColor' | 'borderRadiusPx'
149
+ >,
150
+ ): {
151
+ backgroundColor?: string;
152
+ borderColor?: string;
153
+ borderWidth?: string;
154
+ borderStyle?: 'solid';
155
+ borderRadius?: string;
156
+ } | undefined {
157
+ const style: {
158
+ backgroundColor?: string;
159
+ borderColor?: string;
160
+ borderWidth?: string;
161
+ borderStyle?: 'solid';
162
+ borderRadius?: string;
163
+ } = {};
164
+ if (settings.backgroundColor) {
165
+ style.backgroundColor = settings.backgroundColor;
166
+ }
167
+ if (settings.borderColor) {
168
+ style.borderColor = settings.borderColor;
169
+ style.borderWidth = '1px';
170
+ style.borderStyle = 'solid';
171
+ }
172
+ if (settings.borderRadiusPx !== undefined) {
173
+ style.borderRadius = `${settings.borderRadiusPx}px`;
174
+ }
175
+ return Object.keys(style).length > 0 ? style : undefined;
176
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createBlockInstance } from '@shoppex/builder-contracts';
3
+ import { createElement } from 'react';
4
+ import { renderToStaticMarkup } from 'react-dom/server';
5
+ import { createStandardProductBlockRegistry } from './standard-product-blocks.js';
6
+
7
+ describe('createStandardProductBlockRegistry', () => {
8
+ test('registers all standard product block types and resolves labels through slots', () => {
9
+ const registry = createStandardProductBlockRegistry({
10
+ data: {
11
+ filteredDescription: 'Description body',
12
+ faqCount: 1,
13
+ reviewCount: 2,
14
+ relatedProductsCount: 1,
15
+ },
16
+ slots: {
17
+ renderGallery: ({ attrs }) => createElement('div', { ...attrs, 'data-slot': 'gallery' }),
18
+ renderBuyBox: ({ attrs, labels }) =>
19
+ createElement('div', {
20
+ ...attrs,
21
+ 'data-slot': 'buy-box',
22
+ 'data-variant-label': labels.variantLabel ?? '',
23
+ }),
24
+ renderDetails: ({ attrs, tabs }) =>
25
+ createElement('div', {
26
+ ...attrs,
27
+ 'data-slot': 'details',
28
+ 'data-tab-count': String(tabs.length),
29
+ }),
30
+ renderRelatedProducts: ({ attrs, title }) =>
31
+ createElement('div', {
32
+ ...attrs,
33
+ 'data-slot': 'related-products',
34
+ 'data-title': title ?? '',
35
+ }),
36
+ },
37
+ });
38
+
39
+ expect(Object.keys(registry).sort()).toEqual([
40
+ 'buy-box',
41
+ 'details',
42
+ 'gallery',
43
+ 'related-products',
44
+ ]);
45
+
46
+ const buyBoxBlock = createBlockInstance({
47
+ id: 'buy-box-1',
48
+ type: 'buy-box',
49
+ settings: { variantLabel: 'Variant' },
50
+ });
51
+ const detailsBlock = createBlockInstance({ id: 'details-1', type: 'details' });
52
+ const relatedBlock = createBlockInstance({
53
+ id: 'related-1',
54
+ type: 'related-products',
55
+ settings: { title: 'More products' },
56
+ });
57
+
58
+ const buyBoxMarkup = renderToStaticMarkup(
59
+ createElement(registry['buy-box'], { block: buyBoxBlock, context: null }),
60
+ );
61
+ const detailsMarkup = renderToStaticMarkup(
62
+ createElement(registry.details, { block: detailsBlock, context: null }),
63
+ );
64
+ const relatedMarkup = renderToStaticMarkup(
65
+ createElement(registry['related-products'], { block: relatedBlock, context: null }),
66
+ );
67
+
68
+ expect(buyBoxMarkup).toContain('data-variant-label="Variant"');
69
+ expect(detailsMarkup).toContain('data-tab-count="3"');
70
+ expect(relatedMarkup).toContain('data-title="More products"');
71
+ });
72
+
73
+ test('skips related-products output when there are no related products', () => {
74
+ const registry = createStandardProductBlockRegistry({
75
+ data: {
76
+ filteredDescription: '',
77
+ faqCount: 0,
78
+ reviewCount: 0,
79
+ relatedProductsCount: 0,
80
+ },
81
+ slots: {
82
+ renderGallery: () => null,
83
+ renderBuyBox: () => null,
84
+ renderDetails: () => null,
85
+ renderRelatedProducts: () => createElement('div', { 'data-slot': 'related-products' }),
86
+ },
87
+ });
88
+
89
+ const relatedBlock = createBlockInstance({ id: 'related-1', type: 'related-products' });
90
+ const output = registry['related-products']({ block: relatedBlock, context: null });
91
+ expect(output).toBeNull();
92
+ });
93
+ });
@@ -0,0 +1,121 @@
1
+ import type { BlockInstance } from '@shoppex/builder-contracts';
2
+ import type { ReactNode } from 'react';
3
+ import { getProductPageBlockAttributes } from './product-page.js';
4
+ import {
5
+ buildStandardProductInfoTabs,
6
+ resolveStandardBuyBoxLabels,
7
+ resolveStandardDetailsLabels,
8
+ resolveStandardRelatedProductsTitle,
9
+ type StandardBuyBoxLabels,
10
+ type StandardDetailsLabels,
11
+ type StandardProductTabSpec,
12
+ } from './standard-product-page.js';
13
+ import { type BuilderBlockRegistry } from './react.js';
14
+
15
+ export {
16
+ STANDARD_PRODUCT_BLOCK_TYPES,
17
+ STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES,
18
+ STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES,
19
+ splitStandardProductPageBlocks,
20
+ buildStandardProductInfoTabs,
21
+ resolveStandardBuyBoxLabels,
22
+ resolveStandardDetailsLabels,
23
+ resolveStandardRelatedProductsTitle,
24
+ resolveScopedBlockSettingText,
25
+ } from './standard-product-page.js';
26
+ export type {
27
+ StandardBuyBoxLabels,
28
+ StandardDetailsLabels,
29
+ StandardProductTabSpec,
30
+ StandardProductBlockType,
31
+ StandardProductSettingScope,
32
+ } from './standard-product-page.js';
33
+
34
+ type StandardProductBlockAttrs = ReturnType<typeof getProductPageBlockAttributes>;
35
+
36
+ export type StandardProductBlockRegistrySlots = {
37
+ renderGallery: (input: {
38
+ block: BlockInstance;
39
+ attrs: StandardProductBlockAttrs;
40
+ }) => ReactNode;
41
+ renderBuyBox: (input: {
42
+ block: BlockInstance;
43
+ attrs: StandardProductBlockAttrs;
44
+ labels: StandardBuyBoxLabels;
45
+ }) => ReactNode;
46
+ renderDetails: (input: {
47
+ block: BlockInstance;
48
+ attrs: StandardProductBlockAttrs;
49
+ labels: StandardDetailsLabels;
50
+ tabs: StandardProductTabSpec[];
51
+ }) => ReactNode;
52
+ renderRelatedProducts: (input: {
53
+ block: BlockInstance;
54
+ attrs: StandardProductBlockAttrs;
55
+ title: string | null;
56
+ }) => ReactNode | null;
57
+ };
58
+
59
+ export type StandardProductBlockRegistryOptions = {
60
+ useScopedKeys?: boolean;
61
+ includeReviewsTab?: boolean;
62
+ useShopReviewTabLabel?: boolean;
63
+ };
64
+
65
+ export type StandardProductBlockRegistryData = {
66
+ filteredDescription: string;
67
+ faqCount: number;
68
+ reviewCount: number;
69
+ reviewSource?: 'shop' | 'product';
70
+ relatedProductsCount: number;
71
+ };
72
+
73
+ export function createStandardProductBlockRegistry(input: {
74
+ slots: StandardProductBlockRegistrySlots;
75
+ data: StandardProductBlockRegistryData;
76
+ options?: StandardProductBlockRegistryOptions;
77
+ }): BuilderBlockRegistry<null> {
78
+ const { slots, data, options = {} } = input;
79
+
80
+ return {
81
+ gallery: ({ block }) =>
82
+ slots.renderGallery({
83
+ block,
84
+ attrs: getProductPageBlockAttributes(block),
85
+ }),
86
+ 'buy-box': ({ block }) =>
87
+ slots.renderBuyBox({
88
+ block,
89
+ attrs: getProductPageBlockAttributes(block),
90
+ labels: resolveStandardBuyBoxLabels(block, options),
91
+ }),
92
+ details: ({ block }) => {
93
+ const labels = resolveStandardDetailsLabels(block, options);
94
+ const tabs = buildStandardProductInfoTabs({
95
+ labels,
96
+ filteredDescription: data.filteredDescription,
97
+ faqCount: data.faqCount,
98
+ reviewCount: data.reviewCount,
99
+ reviewSource: data.reviewSource,
100
+ includeReviewsTab: options.includeReviewsTab ?? true,
101
+ useShopReviewTabLabel: options.useShopReviewTabLabel ?? false,
102
+ });
103
+ return slots.renderDetails({
104
+ block,
105
+ attrs: getProductPageBlockAttributes(block),
106
+ labels,
107
+ tabs,
108
+ });
109
+ },
110
+ 'related-products': ({ block }) => {
111
+ if (data.relatedProductsCount <= 0) {
112
+ return null;
113
+ }
114
+ return slots.renderRelatedProducts({
115
+ block,
116
+ attrs: getProductPageBlockAttributes(block),
117
+ title: resolveStandardRelatedProductsTitle(block, options),
118
+ });
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,171 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createBlockInstance } from '@shoppex/builder-contracts';
3
+ import {
4
+ buildStandardProductInfoTabs,
5
+ resolveScopedBlockSettingText,
6
+ resolveStandardBuyBoxLabels,
7
+ resolveStandardDetailsLabels,
8
+ resolveStandardRelatedProductsTitle,
9
+ splitStandardProductPageBlocks,
10
+ STANDARD_PRODUCT_BLOCK_TYPES,
11
+ } from './standard-product-page.js';
12
+
13
+ describe('standard product page helpers', () => {
14
+ test('STANDARD_PRODUCT_BLOCK_TYPES matches shared manifest block ids', () => {
15
+ expect(STANDARD_PRODUCT_BLOCK_TYPES).toEqual([
16
+ 'gallery',
17
+ 'buy-box',
18
+ 'details',
19
+ 'related-products',
20
+ ]);
21
+ });
22
+
23
+ test('splitStandardProductPageBlocks groups primary and secondary blocks', () => {
24
+ const blocks = [
25
+ createBlockInstance({ id: 'gallery-1', type: 'gallery' }),
26
+ createBlockInstance({ id: 'buy-box-1', type: 'buy-box' }),
27
+ createBlockInstance({ id: 'details-1', type: 'details' }),
28
+ createBlockInstance({ id: 'related-1', type: 'related-products' }),
29
+ ];
30
+
31
+ expect(splitStandardProductPageBlocks(blocks)).toEqual({
32
+ primary: [blocks[0], blocks[1]],
33
+ secondary: [blocks[2], blocks[3]],
34
+ });
35
+ });
36
+
37
+ test('resolveStandardBuyBoxLabels reads short keys by default', () => {
38
+ const block = createBlockInstance({
39
+ type: 'buy-box',
40
+ settings: {
41
+ variantLabel: 'License Type',
42
+ addonsLabel: 'Extras',
43
+ },
44
+ });
45
+
46
+ expect(resolveStandardBuyBoxLabels(block)).toEqual({
47
+ variantLabel: 'License Type',
48
+ addonsLabel: 'Extras',
49
+ primaryActionLabel: null,
50
+ buyNowLabel: null,
51
+ quantityLabel: null,
52
+ reviewsLabel: null,
53
+ noReviewsLabel: null,
54
+ });
55
+ });
56
+
57
+ test('resolveStandardBuyBoxLabels prefers scoped keys when enabled', () => {
58
+ const block = createBlockInstance({
59
+ type: 'buy-box',
60
+ settings: {
61
+ 'product.buyBox.variantLabel': 'Scoped Variant',
62
+ variantLabel: 'Legacy Variant',
63
+ },
64
+ });
65
+
66
+ expect(resolveStandardBuyBoxLabels(block, { useScopedKeys: true }).variantLabel).toBe(
67
+ 'Scoped Variant',
68
+ );
69
+ expect(resolveStandardBuyBoxLabels(block, { useScopedKeys: true }).addonsLabel).toBeNull();
70
+ });
71
+
72
+ test('resolveScopedBlockSettingText falls back to short keys', () => {
73
+ const block = createBlockInstance({
74
+ type: 'details',
75
+ settings: {
76
+ faqTabLabel: 'Questions',
77
+ },
78
+ });
79
+
80
+ expect(resolveScopedBlockSettingText(block, 'details', 'faqTabLabel')).toBe('Questions');
81
+ });
82
+
83
+ test('buildStandardProductInfoTabs includes description, reviews, and faq tabs', () => {
84
+ const labels = resolveStandardDetailsLabels(
85
+ createBlockInstance({
86
+ type: 'details',
87
+ settings: {
88
+ descriptionTabLabel: 'Overview',
89
+ reviewsTabLabel: 'Ratings',
90
+ faqTabLabel: 'Help',
91
+ },
92
+ }),
93
+ );
94
+
95
+ expect(
96
+ buildStandardProductInfoTabs({
97
+ labels,
98
+ filteredDescription: 'Product copy',
99
+ faqCount: 2,
100
+ reviewCount: 5,
101
+ }),
102
+ ).toEqual([
103
+ { id: 'description', label: 'Overview', content: 'Product copy' },
104
+ { id: 'reviews', label: 'Ratings', badge: '5' },
105
+ { id: 'faq', label: 'Help', badge: '2' },
106
+ ]);
107
+ });
108
+
109
+ test('buildStandardProductInfoTabs uses shop review label when requested', () => {
110
+ const labels = resolveStandardDetailsLabels(
111
+ createBlockInstance({
112
+ type: 'details',
113
+ settings: {
114
+ shopReviewsTabLabel: 'Store Reviews',
115
+ reviewsTabLabel: 'Reviews',
116
+ },
117
+ }),
118
+ );
119
+
120
+ expect(
121
+ buildStandardProductInfoTabs({
122
+ labels,
123
+ filteredDescription: '',
124
+ faqCount: 0,
125
+ reviewCount: 3,
126
+ reviewSource: 'shop',
127
+ useShopReviewTabLabel: true,
128
+ }),
129
+ ).toEqual([
130
+ { id: 'description', label: 'Description', content: '' },
131
+ { id: 'reviews', label: 'Store Reviews', badge: '3' },
132
+ ]);
133
+ });
134
+
135
+ test('buildStandardProductInfoTabs omits reviews tab when disabled', () => {
136
+ const labels = resolveStandardDetailsLabels(createBlockInstance({ type: 'details' }));
137
+
138
+ expect(
139
+ buildStandardProductInfoTabs({
140
+ labels,
141
+ filteredDescription: 'Details',
142
+ faqCount: 1,
143
+ reviewCount: 4,
144
+ includeReviewsTab: false,
145
+ }),
146
+ ).toEqual([
147
+ { id: 'description', label: 'Description', content: 'Details' },
148
+ { id: 'faq', label: 'FAQ', badge: '1' },
149
+ ]);
150
+ });
151
+
152
+ test('resolveStandardRelatedProductsTitle reads scoped and short keys', () => {
153
+ const scopedBlock = createBlockInstance({
154
+ type: 'related-products',
155
+ settings: {
156
+ 'product.relatedProducts.title': 'You may also like',
157
+ },
158
+ });
159
+ const shortBlock = createBlockInstance({
160
+ type: 'related-products',
161
+ settings: {
162
+ title: 'Related',
163
+ },
164
+ });
165
+
166
+ expect(resolveStandardRelatedProductsTitle(scopedBlock, { useScopedKeys: true })).toBe(
167
+ 'You may also like',
168
+ );
169
+ expect(resolveStandardRelatedProductsTitle(shortBlock)).toBe('Related');
170
+ });
171
+ });