@shopbb/helium 0.7.7 → 0.9.0

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 (76) hide show
  1. package/dist/analytics/index.d.ts +18 -0
  2. package/dist/analytics/index.d.ts.map +1 -0
  3. package/dist/analytics/index.js +16 -0
  4. package/dist/analytics/index.js.map +1 -0
  5. package/dist/analytics/queue.d.ts +31 -0
  6. package/dist/analytics/queue.d.ts.map +1 -0
  7. package/dist/analytics/queue.js +203 -0
  8. package/dist/analytics/queue.js.map +1 -0
  9. package/dist/analytics/react.d.ts +44 -0
  10. package/dist/analytics/react.d.ts.map +1 -0
  11. package/dist/analytics/react.js +114 -0
  12. package/dist/analytics/react.js.map +1 -0
  13. package/dist/analytics/types.d.ts +68 -0
  14. package/dist/analytics/types.d.ts.map +1 -0
  15. package/dist/analytics/types.js +7 -0
  16. package/dist/analytics/types.js.map +1 -0
  17. package/dist/components/AddToCartButton.d.ts +7 -0
  18. package/dist/components/AddToCartButton.d.ts.map +1 -1
  19. package/dist/components/AddToCartButton.js +13 -2
  20. package/dist/components/AddToCartButton.js.map +1 -1
  21. package/dist/page-schema/PageRenderer.d.ts +29 -0
  22. package/dist/page-schema/PageRenderer.d.ts.map +1 -0
  23. package/dist/page-schema/PageRenderer.js +73 -0
  24. package/dist/page-schema/PageRenderer.js.map +1 -0
  25. package/dist/page-schema/index.d.ts +20 -0
  26. package/dist/page-schema/index.d.ts.map +1 -0
  27. package/dist/page-schema/index.js +18 -0
  28. package/dist/page-schema/index.js.map +1 -0
  29. package/dist/page-schema/sections/Banner.d.ts +10 -0
  30. package/dist/page-schema/sections/Banner.d.ts.map +1 -0
  31. package/dist/page-schema/sections/Banner.js +35 -0
  32. package/dist/page-schema/sections/Banner.js.map +1 -0
  33. package/dist/page-schema/sections/Hero.d.ts +15 -0
  34. package/dist/page-schema/sections/Hero.d.ts.map +1 -0
  35. package/dist/page-schema/sections/Hero.js +44 -0
  36. package/dist/page-schema/sections/Hero.js.map +1 -0
  37. package/dist/page-schema/sections/Image.d.ts +10 -0
  38. package/dist/page-schema/sections/Image.d.ts.map +1 -0
  39. package/dist/page-schema/sections/Image.js +12 -0
  40. package/dist/page-schema/sections/Image.js.map +1 -0
  41. package/dist/page-schema/sections/ProductGrid.d.ts +29 -0
  42. package/dist/page-schema/sections/ProductGrid.d.ts.map +1 -0
  43. package/dist/page-schema/sections/ProductGrid.js +28 -0
  44. package/dist/page-schema/sections/ProductGrid.js.map +1 -0
  45. package/dist/page-schema/sections/RichText.d.ts +13 -0
  46. package/dist/page-schema/sections/RichText.d.ts.map +1 -0
  47. package/dist/page-schema/sections/RichText.js +16 -0
  48. package/dist/page-schema/sections/RichText.js.map +1 -0
  49. package/dist/page-schema/sections/Spacer.d.ts +10 -0
  50. package/dist/page-schema/sections/Spacer.d.ts.map +1 -0
  51. package/dist/page-schema/sections/Spacer.js +6 -0
  52. package/dist/page-schema/sections/Spacer.js.map +1 -0
  53. package/dist/page-schema/types.d.ts +138 -0
  54. package/dist/page-schema/types.d.ts.map +1 -0
  55. package/dist/page-schema/types.js +129 -0
  56. package/dist/page-schema/types.js.map +1 -0
  57. package/dist/react.d.ts +7 -0
  58. package/dist/react.d.ts.map +1 -1
  59. package/dist/react.js +15 -0
  60. package/dist/react.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/analytics/index.ts +27 -0
  63. package/src/analytics/queue.ts +224 -0
  64. package/src/analytics/react.tsx +146 -0
  65. package/src/analytics/types.ts +81 -0
  66. package/src/components/AddToCartButton.tsx +18 -0
  67. package/src/page-schema/PageRenderer.tsx +147 -0
  68. package/src/page-schema/index.ts +48 -0
  69. package/src/page-schema/sections/Banner.tsx +63 -0
  70. package/src/page-schema/sections/Hero.tsx +92 -0
  71. package/src/page-schema/sections/Image.tsx +42 -0
  72. package/src/page-schema/sections/ProductGrid.tsx +96 -0
  73. package/src/page-schema/sections/RichText.tsx +49 -0
  74. package/src/page-schema/sections/Spacer.tsx +15 -0
  75. package/src/page-schema/types.ts +286 -0
  76. package/src/react.tsx +59 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Analytics SDK 类型
3
+ *
4
+ * Helium 的埋点接口。与 platform-api 的事件 schema 保持一致。
5
+ */
6
+
7
+ export type EventType =
8
+ | 'page_view'
9
+ | 'product_view'
10
+ | 'add_to_cart'
11
+ | 'checkout_start'
12
+ | 'checkout_paid';
13
+
14
+ /** 单条事件输入 */
15
+ export interface AnalyticsEvent {
16
+ /** 事件类型 */
17
+ type: EventType;
18
+ /** 客户端发生时间(秒,Unix)。默认 Date.now()/1000 */
19
+ occurredAt?: number;
20
+ /** 页面 path(默认 window.location.pathname) */
21
+ path?: string;
22
+ /** referrer(默认 document.referrer) */
23
+ referrer?: string;
24
+ /** 事件附加数据 */
25
+ props?: Record<string, unknown>;
26
+ }
27
+
28
+ /** 各事件的 props 形状 */
29
+ export interface PageViewProps {
30
+ title?: string;
31
+ }
32
+
33
+ export interface ProductViewProps {
34
+ productId: string;
35
+ variantId?: string;
36
+ priceCents?: number;
37
+ currency?: string;
38
+ }
39
+
40
+ export interface AddToCartProps {
41
+ productId: string;
42
+ variantId?: string;
43
+ quantity: number;
44
+ priceCents?: number;
45
+ currency?: string;
46
+ }
47
+
48
+ export interface CheckoutStartProps {
49
+ cartId?: string;
50
+ itemCount: number;
51
+ subtotalCents: number;
52
+ currency: string;
53
+ }
54
+
55
+ /** AnalyticsProvider 初始化配置 */
56
+ export interface AnalyticsConfig {
57
+ /** 平台 API base URL,例如 https://api.oxygen-demo.cloudc.top */
58
+ apiBase: string;
59
+ /** Storefront public access token */
60
+ publicAccessToken: string;
61
+ /** 当前买家 ID(已登录时传入) */
62
+ buyerId?: string | null;
63
+ /** 是否开启 debug 日志 */
64
+ debug?: boolean;
65
+ /** flush 间隔(毫秒),默认 3000 */
66
+ flushIntervalMs?: number;
67
+ /** 批量上限(条),默认 20 */
68
+ flushBatchSize?: number;
69
+ }
70
+
71
+ /** track 函数签名 */
72
+ export type TrackFn = (event: AnalyticsEvent) => void;
73
+
74
+ /** 内部队列条目 */
75
+ export interface QueuedEvent {
76
+ type: EventType;
77
+ occurredAt: number;
78
+ path: string | null;
79
+ referrer: string | null;
80
+ props: Record<string, unknown>;
81
+ }
@@ -39,6 +39,12 @@ export interface AddToCartButtonProps {
39
39
  route?: string;
40
40
  /** 按钮 children */
41
41
  children?: React.ReactNode;
42
+ /**
43
+ * 按钮被点击时触发(form 提交之前)。
44
+ * 用于埋点 / dataLayer / 自定义分析回调。
45
+ * 不影响表单提交流程。
46
+ */
47
+ onAdd?: (line: CartLineInput) => void;
42
48
  }
43
49
 
44
50
  export function AddToCartButton(props: AddToCartButtonProps) {
@@ -53,6 +59,7 @@ export function AddToCartButton(props: AddToCartButtonProps) {
53
59
  className,
54
60
  route = '/cart',
55
61
  children = '加入购物车',
62
+ onAdd,
56
63
  } = props;
57
64
 
58
65
  const line: CartLineInput = { merchandiseId: variantId, quantity };
@@ -74,6 +81,17 @@ export function AddToCartButton(props: AddToCartButtonProps) {
74
81
  disabled={disabled || submitting}
75
82
  data-add-to-cart
76
83
  data-loading={submitting ? '' : undefined}
84
+ onClick={() => {
85
+ if (onAdd && !submitting && !disabled) {
86
+ try {
87
+ onAdd(line);
88
+ } catch (err) {
89
+ if (typeof console !== 'undefined') {
90
+ console.warn('[AddToCartButton] onAdd threw', err);
91
+ }
92
+ }
93
+ }
94
+ }}
77
95
  >
78
96
  {submitting ? loadingText : disabled ? unavailableText : children}
79
97
  </button>
@@ -0,0 +1,147 @@
1
+ /**
2
+ * <PageRenderer> — 按 Page Schema 渲染一整页
3
+ *
4
+ * 同一份组件在 production storefront 与 admin 编辑器画布共用。
5
+ *
6
+ * 商品网格的 selection_mode 自动模式(newest / top-selling / collection)
7
+ * 由父组件预先拉数据通过 productsBySection prop 注入,组件本身不发请求。
8
+ * 这让 renderer 保持纯组件、可在 SSR / CSR / 编辑器画布任何环境用。
9
+ */
10
+
11
+ import * as React from 'react';
12
+ import type { AnySection, Page } from './types';
13
+ import { HeroSection } from './sections/Hero';
14
+ import { ProductGridSection } from './sections/ProductGrid';
15
+ import { BannerSection } from './sections/Banner';
16
+ import { RichTextSection } from './sections/RichText';
17
+ import { ImageSection } from './sections/Image';
18
+ import { SpacerSection } from './sections/Spacer';
19
+
20
+ export interface PageRendererProps {
21
+ page: Page;
22
+ /**
23
+ * 按 section.id 提供该 section 用到的商品数据(仅 product-grid 用到)。
24
+ * Page renderer 不发请求,请父组件按 schema 计算后传入。
25
+ */
26
+ productsBySection?: Record<string, any[]>;
27
+ /**
28
+ * 编辑器模式下传入 onSectionClick / selectedSectionId,渲染时会给每个 section
29
+ * 加 hover / 选中外框,便于点选定位。
30
+ */
31
+ editorMode?: {
32
+ selectedSectionId?: string | null;
33
+ onSectionClick?: (sectionId: string) => void;
34
+ };
35
+ }
36
+
37
+ export function PageRenderer({
38
+ page,
39
+ productsBySection,
40
+ editorMode,
41
+ }: PageRendererProps): React.ReactElement {
42
+ const globalStyle: React.CSSProperties = {
43
+ backgroundColor: page.global_style?.background_color,
44
+ color: '#0f172a',
45
+ fontFamily:
46
+ page.global_style?.font_family === 'serif'
47
+ ? 'Georgia, "Times New Roman", serif'
48
+ : page.global_style?.font_family === 'sans-serif'
49
+ ? '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
50
+ : 'inherit',
51
+ };
52
+
53
+ return (
54
+ <div style={globalStyle} data-page-slug={page.slug}>
55
+ {page.sections
56
+ .filter((s) => s.visible !== false)
57
+ .map((section) => (
58
+ <SectionFrame
59
+ key={section.id}
60
+ section={section}
61
+ productsBySection={productsBySection}
62
+ editorMode={editorMode}
63
+ />
64
+ ))}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ interface SectionFrameProps {
70
+ section: AnySection;
71
+ productsBySection?: Record<string, any[]>;
72
+ editorMode?: PageRendererProps['editorMode'];
73
+ }
74
+
75
+ function SectionFrame({ section, productsBySection, editorMode }: SectionFrameProps): React.ReactElement {
76
+ const isSelected = editorMode?.selectedSectionId === section.id;
77
+ const isEditor = !!editorMode;
78
+
79
+ const handleClick = (e: React.MouseEvent) => {
80
+ if (!isEditor) return;
81
+ // 阻止内部 a 链接跳转,让商家专注于选中
82
+ e.preventDefault();
83
+ e.stopPropagation();
84
+ editorMode?.onSectionClick?.(section.id);
85
+ };
86
+
87
+ const editorStyle: React.CSSProperties = isEditor
88
+ ? {
89
+ position: 'relative',
90
+ cursor: 'pointer',
91
+ outline: isSelected ? '2px solid #f97316' : '2px solid transparent',
92
+ outlineOffset: '-2px',
93
+ transition: 'outline-color 120ms ease',
94
+ }
95
+ : {};
96
+
97
+ const onMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
98
+ if (!isEditor || isSelected) return;
99
+ (e.currentTarget as HTMLDivElement).style.outlineColor = '#fed7aa';
100
+ };
101
+ const onMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
102
+ if (!isEditor || isSelected) return;
103
+ (e.currentTarget as HTMLDivElement).style.outlineColor = 'transparent';
104
+ };
105
+
106
+ return (
107
+ <div
108
+ data-section-id={section.id}
109
+ data-section-type={section.type}
110
+ style={editorStyle}
111
+ onClick={handleClick}
112
+ onMouseEnter={onMouseEnter}
113
+ onMouseLeave={onMouseLeave}
114
+ >
115
+ {renderSection(section, productsBySection)}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ function renderSection(section: AnySection, productsBySection?: Record<string, any[]>) {
121
+ switch (section.type) {
122
+ case 'hero':
123
+ return <HeroSection data={section.data} />;
124
+ case 'product-grid':
125
+ return (
126
+ <ProductGridSection
127
+ data={section.data}
128
+ products={productsBySection?.[section.id]}
129
+ />
130
+ );
131
+ case 'banner':
132
+ return <BannerSection data={section.data} />;
133
+ case 'rich-text':
134
+ return <RichTextSection data={section.data} />;
135
+ case 'image':
136
+ return <ImageSection data={section.data} />;
137
+ case 'spacer':
138
+ return <SpacerSection data={section.data} />;
139
+ default:
140
+ // exhaustive check
141
+ return (
142
+ <div style={{ padding: 16, background: '#fef2f2', color: '#b91c1c' }}>
143
+ 未知 section 类型:{(section as any).type}
144
+ </div>
145
+ );
146
+ }
147
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @shopbb/helium 的 Page Schema 模块
3
+ *
4
+ * 公开 API:
5
+ * - types:Page / Section / 各 section 的 data 类型
6
+ * - <PageRenderer>:按 schema 渲染一整页
7
+ * - 6 个 section 组件(也可以单独引用)
8
+ * - 工具:createSection / createEmptyPage / validatePage / DEFAULT_DATA
9
+ */
10
+
11
+ export type {
12
+ Page,
13
+ PageGlobalStyle,
14
+ Section,
15
+ AnySection,
16
+ SectionType,
17
+ HeroData,
18
+ HeroSection as HeroSectionType,
19
+ ProductGridData,
20
+ ProductGridSection as ProductGridSectionType,
21
+ BannerData,
22
+ BannerSection as BannerSectionType,
23
+ RichTextData,
24
+ RichTextSection as RichTextSectionType,
25
+ ImageData,
26
+ ImageSection as ImageSectionType,
27
+ SpacerData,
28
+ SpacerSection as SpacerSectionType,
29
+ } from './types';
30
+
31
+ export {
32
+ newSectionId,
33
+ createSection,
34
+ createEmptyPage,
35
+ validateSection,
36
+ validatePage,
37
+ DEFAULT_DATA,
38
+ } from './types';
39
+
40
+ export { PageRenderer } from './PageRenderer';
41
+ export type { PageRendererProps } from './PageRenderer';
42
+
43
+ export { HeroSection } from './sections/Hero';
44
+ export { ProductGridSection } from './sections/ProductGrid';
45
+ export { BannerSection } from './sections/Banner';
46
+ export { RichTextSection } from './sections/RichText';
47
+ export { ImageSection } from './sections/Image';
48
+ export { SpacerSection } from './sections/Spacer';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * <BannerSection> — 顶部细条状横幅
3
+ */
4
+
5
+ import * as React from 'react';
6
+ import type { BannerData } from '../types';
7
+
8
+ export interface BannerSectionProps {
9
+ data: BannerData;
10
+ }
11
+
12
+ export function BannerSection({ data }: BannerSectionProps): React.ReactElement | null {
13
+ const [dismissed, setDismissed] = React.useState(false);
14
+ if (dismissed) return null;
15
+
16
+ const style: React.CSSProperties = {
17
+ padding: '12px 32px',
18
+ background: data.background_color ?? '#f97316',
19
+ color: data.text_color ?? '#ffffff',
20
+ display: 'flex',
21
+ alignItems: 'center',
22
+ justifyContent: 'center',
23
+ gap: 14,
24
+ fontSize: 14,
25
+ fontWeight: 500,
26
+ };
27
+
28
+ return (
29
+ <div style={style}>
30
+ <span>{data.text}</span>
31
+ {data.link && (
32
+ <a
33
+ href={data.link}
34
+ style={{
35
+ color: 'inherit',
36
+ textDecoration: 'underline',
37
+ fontWeight: 600,
38
+ }}
39
+ >
40
+ {data.link_label ?? '查看详情'}
41
+ </a>
42
+ )}
43
+ {data.dismissible && (
44
+ <button
45
+ type="button"
46
+ aria-label="关闭"
47
+ onClick={() => setDismissed(true)}
48
+ style={{
49
+ marginLeft: 'auto',
50
+ background: 'transparent',
51
+ border: 'none',
52
+ color: 'inherit',
53
+ cursor: 'pointer',
54
+ fontSize: 18,
55
+ opacity: 0.8,
56
+ }}
57
+ >
58
+ ×
59
+ </button>
60
+ )}
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * <HeroSection> — 渲染 hero 区块
3
+ *
4
+ * 这是按 Page Schema 设计的纯渲染组件:
5
+ * - props 直接接收 HeroData
6
+ * - 不读取 schema,不操作 schema,只渲染
7
+ * - 编辑器与 production storefront 共用同一份组件
8
+ */
9
+
10
+ import * as React from 'react';
11
+ import type { HeroData } from '../types';
12
+
13
+ export interface HeroSectionProps {
14
+ data: HeroData;
15
+ }
16
+
17
+ const HEIGHT_MAP: Record<NonNullable<HeroData['height']>, string> = {
18
+ small: '240px',
19
+ medium: '380px',
20
+ large: '540px',
21
+ full: '85vh',
22
+ };
23
+
24
+ export function HeroSection({ data }: HeroSectionProps): React.ReactElement {
25
+ const height = HEIGHT_MAP[data.height ?? 'medium'];
26
+ const align = data.text_align ?? 'center';
27
+
28
+ const style: React.CSSProperties = {
29
+ position: 'relative',
30
+ minHeight: height,
31
+ display: 'flex',
32
+ flexDirection: 'column',
33
+ justifyContent: 'center',
34
+ alignItems: align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start',
35
+ padding: '64px 32px',
36
+ background: data.background_image
37
+ ? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${data.background_image}) center/cover`
38
+ : data.background_color ?? '#0f172a',
39
+ color: data.text_color ?? '#ffffff',
40
+ textAlign: align,
41
+ };
42
+
43
+ return (
44
+ <section style={style}>
45
+ <h1 style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', fontWeight: 800, margin: 0, maxWidth: '24ch' }}>
46
+ {data.headline}
47
+ </h1>
48
+ {data.subheadline && (
49
+ <p style={{ fontSize: 'clamp(1rem, 2vw, 1.25rem)', marginTop: 16, maxWidth: '40ch', opacity: 0.9 }}>
50
+ {data.subheadline}
51
+ </p>
52
+ )}
53
+ {(data.cta_label || data.secondary_cta_label) && (
54
+ <div style={{ marginTop: 28, display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: align }}>
55
+ {data.cta_label && (
56
+ <a
57
+ href={data.cta_link ?? '#'}
58
+ style={{
59
+ padding: '12px 28px',
60
+ background: data.text_color === '#ffffff' ? '#fff' : '#0f172a',
61
+ color: data.text_color === '#ffffff' ? '#0f172a' : '#ffffff',
62
+ borderRadius: 8,
63
+ fontWeight: 600,
64
+ textDecoration: 'none',
65
+ fontSize: 15,
66
+ }}
67
+ >
68
+ {data.cta_label}
69
+ </a>
70
+ )}
71
+ {data.secondary_cta_label && (
72
+ <a
73
+ href={data.secondary_cta_link ?? '#'}
74
+ style={{
75
+ padding: '12px 28px',
76
+ background: 'transparent',
77
+ color: 'inherit',
78
+ border: '1px solid currentColor',
79
+ borderRadius: 8,
80
+ fontWeight: 600,
81
+ textDecoration: 'none',
82
+ fontSize: 15,
83
+ }}
84
+ >
85
+ {data.secondary_cta_label}
86
+ </a>
87
+ )}
88
+ </div>
89
+ )}
90
+ </section>
91
+ );
92
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * <ImageSection> — 单张图片,可加链接
3
+ */
4
+
5
+ import * as React from 'react';
6
+ import type { ImageData } from '../types';
7
+
8
+ export interface ImageSectionProps {
9
+ data: ImageData;
10
+ }
11
+
12
+ const WIDTH_MAP: Record<NonNullable<ImageData['width']>, string> = {
13
+ full: '100%',
14
+ normal: '1200px',
15
+ narrow: '760px',
16
+ };
17
+
18
+ export function ImageSection({ data }: ImageSectionProps): React.ReactElement {
19
+ const maxWidth = WIDTH_MAP[data.width ?? 'normal'];
20
+
21
+ const img = (
22
+ <img
23
+ src={data.url}
24
+ alt={data.alt ?? ''}
25
+ style={{ display: 'block', width: '100%', height: 'auto' }}
26
+ />
27
+ );
28
+
29
+ return (
30
+ <section style={{ padding: data.width === 'full' ? 0 : '32px', textAlign: 'center' }}>
31
+ <div style={{ maxWidth, margin: '0 auto' }}>
32
+ {data.link ? (
33
+ <a href={data.link} style={{ display: 'block' }}>
34
+ {img}
35
+ </a>
36
+ ) : (
37
+ img
38
+ )}
39
+ </div>
40
+ </section>
41
+ );
42
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * <ProductGridSection> — 商品网格
3
+ *
4
+ * 拉取商品数据需要 storefront client,由父组件通过 props 注入避免组件耦合。
5
+ *
6
+ * P0 简化:组件本身不拉数据,仅按传入的 products 数组渲染。Page renderer 负责
7
+ * 在渲染前根据 selection_mode 拉好商品。
8
+ */
9
+
10
+ import * as React from 'react';
11
+ import type { ProductGridData } from '../types';
12
+
13
+ export interface ProductGridSectionProps {
14
+ data: ProductGridData;
15
+ /** 父组件预先拉好的商品数据 */
16
+ products?: Array<{
17
+ id: string;
18
+ handle: string;
19
+ title: string;
20
+ price?: { amount: string; currencyCode: string };
21
+ image?: { url: string; alt?: string };
22
+ }>;
23
+ }
24
+
25
+ export function ProductGridSection({ data, products }: ProductGridSectionProps): React.ReactElement {
26
+ const columns = data.columns ?? 3;
27
+ const list = products ?? [];
28
+
29
+ return (
30
+ <section style={{ padding: '64px 32px', maxWidth: 1280, margin: '0 auto' }}>
31
+ {data.title && (
32
+ <h2 style={{ fontSize: 'clamp(1.5rem, 3vw, 2rem)', margin: 0, fontWeight: 800 }}>{data.title}</h2>
33
+ )}
34
+ {data.subtitle && (
35
+ <p style={{ marginTop: 8, color: '#64748b', fontSize: 15 }}>{data.subtitle}</p>
36
+ )}
37
+ <div
38
+ style={{
39
+ marginTop: 24,
40
+ display: 'grid',
41
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
42
+ gap: 24,
43
+ }}
44
+ >
45
+ {list.length === 0 ? (
46
+ <div
47
+ style={{
48
+ gridColumn: '1 / -1',
49
+ padding: '40px 0',
50
+ textAlign: 'center',
51
+ color: '#94a3b8',
52
+ fontSize: 14,
53
+ }}
54
+ >
55
+ {data.selection_mode === 'manual'
56
+ ? '(暂未选定商品)'
57
+ : '(暂无商品数据,编辑器预览模式不拉取真实数据)'}
58
+ </div>
59
+ ) : (
60
+ list.map((p) => (
61
+ <a
62
+ key={p.id}
63
+ href={`/products/${p.handle}`}
64
+ style={{
65
+ background: '#fff',
66
+ border: '1px solid #e2e8f0',
67
+ borderRadius: 10,
68
+ overflow: 'hidden',
69
+ textDecoration: 'none',
70
+ color: 'inherit',
71
+ display: 'block',
72
+ }}
73
+ >
74
+ {p.image && (
75
+ <img
76
+ src={p.image.url}
77
+ alt={p.image.alt || p.title}
78
+ style={{ width: '100%', aspectRatio: '1 / 1', objectFit: 'cover', display: 'block' }}
79
+ />
80
+ )}
81
+ <div style={{ padding: 14 }}>
82
+ <div style={{ fontSize: 14, fontWeight: 600, color: '#0f172a' }}>{p.title}</div>
83
+ {p.price && (
84
+ <div style={{ marginTop: 6, fontSize: 15, color: '#ea580c', fontWeight: 700 }}>
85
+ {p.price.currencyCode === 'CNY' ? '¥' : p.price.currencyCode + ' '}
86
+ {p.price.amount}
87
+ </div>
88
+ )}
89
+ </div>
90
+ </a>
91
+ ))
92
+ )}
93
+ </div>
94
+ </section>
95
+ );
96
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * <RichTextSection> — 富文本
3
+ *
4
+ * P0:只支持 plain text + 简单换行;markdown 渲染留给商家自己用 marked 库或写成 html
5
+ * mode。等编辑器成熟后再上 markdown 渲染。
6
+ */
7
+
8
+ import * as React from 'react';
9
+ import type { RichTextData } from '../types';
10
+
11
+ export interface RichTextSectionProps {
12
+ data: RichTextData;
13
+ }
14
+
15
+ const WIDTH_MAP: Record<NonNullable<RichTextData['max_width']>, string> = {
16
+ narrow: '640px',
17
+ normal: '880px',
18
+ wide: '1200px',
19
+ };
20
+
21
+ export function RichTextSection({ data }: RichTextSectionProps): React.ReactElement {
22
+ const maxWidth = WIDTH_MAP[data.max_width ?? 'normal'];
23
+
24
+ if (data.format === 'html') {
25
+ return (
26
+ <section style={{ padding: '48px 32px' }}>
27
+ <div
28
+ style={{ maxWidth, margin: '0 auto', lineHeight: 1.7, fontSize: 15.5, color: '#0f172a' }}
29
+ dangerouslySetInnerHTML={{ __html: data.body }}
30
+ />
31
+ </section>
32
+ );
33
+ }
34
+
35
+ // markdown / plain:按段落分割(双换行 = 新段落)
36
+ const paragraphs = data.body.split(/\n{2,}/);
37
+
38
+ return (
39
+ <section style={{ padding: '48px 32px' }}>
40
+ <div style={{ maxWidth, margin: '0 auto', lineHeight: 1.7, fontSize: 15.5, color: '#0f172a' }}>
41
+ {paragraphs.map((p, i) => (
42
+ <p key={i} style={{ margin: i === 0 ? '0' : '1em 0 0', whiteSpace: 'pre-line' }}>
43
+ {p}
44
+ </p>
45
+ ))}
46
+ </div>
47
+ </section>
48
+ );
49
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * <SpacerSection> — 垂直留白
3
+ */
4
+
5
+ import * as React from 'react';
6
+ import type { SpacerData } from '../types';
7
+
8
+ export interface SpacerSectionProps {
9
+ data: SpacerData;
10
+ }
11
+
12
+ export function SpacerSection({ data }: SpacerSectionProps): React.ReactElement {
13
+ const height = typeof data.height === 'number' ? `${data.height}px` : data.height;
14
+ return <div style={{ height }} aria-hidden />;
15
+ }