@shopbb/helium 0.2.0 → 0.3.0-alpha.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 (56) hide show
  1. package/dist/components/AddToCartButton.d.ts +41 -0
  2. package/dist/components/AddToCartButton.d.ts.map +1 -0
  3. package/dist/components/AddToCartButton.js +63 -0
  4. package/dist/components/AddToCartButton.js.map +1 -0
  5. package/dist/components/AnalyticsProvider.d.ts +106 -0
  6. package/dist/components/AnalyticsProvider.d.ts.map +1 -0
  7. package/dist/components/AnalyticsProvider.js +135 -0
  8. package/dist/components/AnalyticsProvider.js.map +1 -0
  9. package/dist/components/CartLineQuantityAdjustButton.d.ts +40 -0
  10. package/dist/components/CartLineQuantityAdjustButton.d.ts.map +1 -0
  11. package/dist/components/CartLineQuantityAdjustButton.js +58 -0
  12. package/dist/components/CartLineQuantityAdjustButton.js.map +1 -0
  13. package/dist/components/CartProvider.d.ts +111 -0
  14. package/dist/components/CartProvider.d.ts.map +1 -0
  15. package/dist/components/CartProvider.js +263 -0
  16. package/dist/components/CartProvider.js.map +1 -0
  17. package/dist/components/Image.d.ts +39 -0
  18. package/dist/components/Image.d.ts.map +1 -0
  19. package/dist/components/Image.js +28 -0
  20. package/dist/components/Image.js.map +1 -0
  21. package/dist/components/Money.d.ts +41 -0
  22. package/dist/components/Money.d.ts.map +1 -0
  23. package/dist/components/Money.js +53 -0
  24. package/dist/components/Money.js.map +1 -0
  25. package/dist/components/ProductOptionsProvider.d.ts +70 -0
  26. package/dist/components/ProductOptionsProvider.d.ts.map +1 -0
  27. package/dist/components/ProductOptionsProvider.js +93 -0
  28. package/dist/components/ProductOptionsProvider.js.map +1 -0
  29. package/dist/components/ProductPrice.d.ts +28 -0
  30. package/dist/components/ProductPrice.d.ts.map +1 -0
  31. package/dist/components/ProductPrice.js +10 -0
  32. package/dist/components/ProductPrice.js.map +1 -0
  33. package/dist/components/ShopProvider.d.ts +49 -0
  34. package/dist/components/ShopProvider.d.ts.map +1 -0
  35. package/dist/components/ShopProvider.js +62 -0
  36. package/dist/components/ShopProvider.js.map +1 -0
  37. package/dist/components/VariantSelector.d.ts +80 -0
  38. package/dist/components/VariantSelector.d.ts.map +1 -0
  39. package/dist/components/VariantSelector.js +87 -0
  40. package/dist/components/VariantSelector.js.map +1 -0
  41. package/dist/components/index.d.ts +41 -0
  42. package/dist/components/index.d.ts.map +1 -0
  43. package/dist/components/index.js +33 -0
  44. package/dist/components/index.js.map +1 -0
  45. package/package.json +12 -4
  46. package/src/components/AddToCartButton.tsx +101 -0
  47. package/src/components/AnalyticsProvider.tsx +175 -0
  48. package/src/components/CartLineQuantityAdjustButton.tsx +119 -0
  49. package/src/components/CartProvider.tsx +378 -0
  50. package/src/components/Image.tsx +93 -0
  51. package/src/components/Money.tsx +112 -0
  52. package/src/components/ProductOptionsProvider.tsx +149 -0
  53. package/src/components/ProductPrice.tsx +61 -0
  54. package/src/components/ShopProvider.tsx +86 -0
  55. package/src/components/VariantSelector.tsx +148 -0
  56. package/src/components/index.ts +71 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * <Image> — 响应式商品图片
3
+ *
4
+ * 对齐 @shopify/hydrogen 的 <Image>:
5
+ * - 自动生成 srcset(5 个分辨率档:375 / 750 / 1024 / 1536 / 1920)
6
+ * - sizes 媒体查询
7
+ * - loading="lazy" 默认(首屏可关)
8
+ * - decoding="async"
9
+ * - aspectRatio 占位(不抖)
10
+ * - 自动 alt
11
+ *
12
+ * srcset 里的 url 通过 ?width= query 表达,服务端可忽略或接 cdn-cgi/image。
13
+ *
14
+ * 用法:
15
+ * <Image data={{ url, altText, width, height }} sizes="(min-width: 768px) 50vw, 100vw" />
16
+ * <Image data={...} aspectRatio="1/1" />
17
+ * <Image data={...} loading="eager" /> // 首屏
18
+ */
19
+
20
+ import * as React from 'react';
21
+
22
+ export interface ImageData {
23
+ url: string;
24
+ altText?: string | null;
25
+ width?: number | null;
26
+ height?: number | null;
27
+ }
28
+
29
+ export interface ImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet' | 'loading'> {
30
+ /** 必传:图片对象 */
31
+ data: ImageData;
32
+ /** 媒体查询 sizes,例如 (min-width: 768px) 50vw, 100vw */
33
+ sizes?: string;
34
+ /** 强制宽高比,例如 '1/1' '4/3' '16/9' */
35
+ aspectRatio?: string;
36
+ /** lazy(默认) / eager(首屏图用) */
37
+ loading?: 'lazy' | 'eager';
38
+ /** 自定义 srcset 档位 */
39
+ widths?: number[];
40
+ }
41
+
42
+ // 默认 srcset 档位
43
+ const DEFAULT_WIDTHS = [375, 750, 1024, 1536, 1920];
44
+
45
+ /** 给 url 加 ?width=N query */
46
+ function withWidth(url: string, width: number): string {
47
+ try {
48
+ const u = new URL(url);
49
+ u.searchParams.set('width', String(width));
50
+ return u.toString();
51
+ } catch {
52
+ // 不是绝对 URL,按相对路径处理
53
+ const sep = url.includes('?') ? '&' : '?';
54
+ return `${url}${sep}width=${width}`;
55
+ }
56
+ }
57
+
58
+ export function Image(props: ImageProps) {
59
+ const {
60
+ data,
61
+ sizes,
62
+ aspectRatio,
63
+ loading = 'lazy',
64
+ widths = DEFAULT_WIDTHS,
65
+ style,
66
+ alt: altProp,
67
+ ...rest
68
+ } = props;
69
+
70
+ const srcset = widths.map((w) => `${withWidth(data.url, w)} ${w}w`).join(', ');
71
+ // src 用中间档位
72
+ const src = withWidth(data.url, widths[Math.floor(widths.length / 2)]);
73
+
74
+ const finalStyle: React.CSSProperties = {
75
+ ...(aspectRatio ? { aspectRatio, objectFit: 'cover' as const } : null),
76
+ ...style,
77
+ };
78
+
79
+ return (
80
+ <img
81
+ {...rest}
82
+ src={src}
83
+ srcSet={srcset}
84
+ sizes={sizes}
85
+ alt={altProp ?? data.altText ?? ''}
86
+ loading={loading}
87
+ decoding="async"
88
+ width={data.width || undefined}
89
+ height={data.height || undefined}
90
+ style={finalStyle}
91
+ />
92
+ );
93
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * <Money> — 货币格式化
3
+ *
4
+ * 对齐 @shopify/hydrogen 的 <Money>。
5
+ * - 自动按 currencyCode + locale 用 Intl.NumberFormat 格式化
6
+ * - 支持 withoutCurrency / withoutTrailingZeros
7
+ * - 支持 as 渲染成任意元素(默认 <span>)
8
+ * - 支持 measurement(unit price,例如 ¥9.99/100g)
9
+ *
10
+ * 用法:
11
+ * <Money data={{ amount: '9.99', currencyCode: 'CNY' }} />
12
+ * <Money data={{ ... }} withoutCurrency />
13
+ * <Money data={{ ... }} as="div" measurement={{ referenceUnit: 'kg', quantity: 0.1 }} />
14
+ */
15
+
16
+ import * as React from 'react';
17
+ import { useShopOptional } from './ShopProvider';
18
+
19
+ export interface MoneyData {
20
+ amount: string;
21
+ currencyCode: string;
22
+ }
23
+
24
+ export interface MoneyMeasurement {
25
+ /** 比如 'kg' / '100g' / 'l' */
26
+ referenceUnit: string;
27
+ /** 比如 0.1 表示每 100g */
28
+ quantity?: number;
29
+ }
30
+
31
+ export interface MoneyProps extends React.HTMLAttributes<HTMLElement> {
32
+ /** 必传:金额对象 */
33
+ data: MoneyData;
34
+ /** 不显示货币符号,只显示数字 */
35
+ withoutCurrency?: boolean;
36
+ /** 整数时不显示 .00 */
37
+ withoutTrailingZeros?: boolean;
38
+ /** locale 字符串(zh-CN / en-US / ...)。默认按 currencyCode 推断 */
39
+ locale?: string;
40
+ /** 渲染元素,默认 span */
41
+ as?: keyof JSX.IntrinsicElements;
42
+ /** unit price:渲染 ¥99.00/100g */
43
+ measurement?: MoneyMeasurement;
44
+ }
45
+
46
+ // currencyCode → 默认 locale 推断
47
+ const DEFAULT_LOCALE_BY_CURRENCY: Record<string, string> = {
48
+ CNY: 'zh-CN',
49
+ USD: 'en-US',
50
+ EUR: 'de-DE',
51
+ GBP: 'en-GB',
52
+ JPY: 'ja-JP',
53
+ KRW: 'ko-KR',
54
+ };
55
+
56
+ export function Money(props: MoneyProps) {
57
+ const {
58
+ data,
59
+ withoutCurrency,
60
+ withoutTrailingZeros,
61
+ locale: localeProp,
62
+ as,
63
+ measurement,
64
+ ...rest
65
+ } = props;
66
+
67
+ const Tag: any = as || 'span';
68
+ const shop = useShopOptional();
69
+ const locale =
70
+ localeProp ||
71
+ shop?.locale ||
72
+ DEFAULT_LOCALE_BY_CURRENCY[data.currencyCode] ||
73
+ 'en-US';
74
+ const amount = Number(data.amount);
75
+
76
+ let formatted: string;
77
+ try {
78
+ const opts: Intl.NumberFormatOptions = withoutCurrency
79
+ ? {
80
+ minimumFractionDigits: withoutTrailingZeros && Number.isInteger(amount) ? 0 : 2,
81
+ maximumFractionDigits: 2,
82
+ }
83
+ : {
84
+ style: 'currency',
85
+ currency: data.currencyCode,
86
+ minimumFractionDigits: withoutTrailingZeros && Number.isInteger(amount) ? 0 : 2,
87
+ maximumFractionDigits: 2,
88
+ };
89
+ formatted = new Intl.NumberFormat(locale, opts).format(amount);
90
+ } catch {
91
+ // Fallback:Intl 不支持的 currencyCode
92
+ formatted = withoutCurrency ? amount.toFixed(2) : `${data.currencyCode} ${amount.toFixed(2)}`;
93
+ }
94
+
95
+ // measurement suffix(unit price)
96
+ let suffix = '';
97
+ if (measurement) {
98
+ const q = measurement.quantity ?? 1;
99
+ if (q === 1) {
100
+ suffix = `/${measurement.referenceUnit}`;
101
+ } else {
102
+ suffix = `/${q}${measurement.referenceUnit}`;
103
+ }
104
+ }
105
+
106
+ return (
107
+ <Tag {...rest} data-money={data.amount} data-money-currency={data.currencyCode}>
108
+ {formatted}
109
+ {suffix}
110
+ </Tag>
111
+ );
112
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * <ProductOptionsProvider> + useProductOptions()
3
+ *
4
+ * 给商品详情页用。包住 product 后,自动管理 selectedVariantId 状态 +
5
+ * 暴露当前 variant、available options、setOption 等。
6
+ *
7
+ * 用法:
8
+ * <ProductOptionsProvider product={product}>
9
+ * <ProductDetailBody />
10
+ * </ProductOptionsProvider>
11
+ *
12
+ * function ProductDetailBody() {
13
+ * const { selectedVariant, options, setOptionValue } = useProductOptions();
14
+ * return ...;
15
+ * }
16
+ */
17
+
18
+ import * as React from 'react';
19
+
20
+ interface SelectedOption { name: string; value: string; }
21
+
22
+ interface ProductVariantLike {
23
+ id: string;
24
+ availableForSale: boolean;
25
+ selectedOptions?: SelectedOption[];
26
+ price?: { amount: string; currencyCode: string };
27
+ compareAtPrice?: { amount: string; currencyCode: string } | null;
28
+ }
29
+
30
+ interface ProductLike {
31
+ id: string;
32
+ options?: Array<{ name: string; values: string[] }>;
33
+ variants: { nodes: ProductVariantLike[] };
34
+ }
35
+
36
+ export interface ProductOptionItem {
37
+ name: string;
38
+ values: Array<{ value: string; available: boolean; isSelected: boolean }>;
39
+ }
40
+
41
+ export interface ProductOptionsContextValue {
42
+ product: ProductLike;
43
+ selectedVariant: ProductVariantLike | null;
44
+ selectedOptions: Record<string, string>;
45
+ options: ProductOptionItem[];
46
+ setOptionValue: (name: string, value: string) => void;
47
+ setVariantById: (variantId: string) => void;
48
+ }
49
+
50
+ const Ctx = React.createContext<ProductOptionsContextValue | null>(null);
51
+
52
+ export interface ProductOptionsProviderProps {
53
+ product: ProductLike;
54
+ /** 初始 selectedVariantId;默认第一个可购 variant */
55
+ initialVariantId?: string;
56
+ children: React.ReactNode;
57
+ }
58
+
59
+ export function ProductOptionsProvider(props: ProductOptionsProviderProps) {
60
+ const { product, initialVariantId, children } = props;
61
+ const variants = product.variants.nodes;
62
+
63
+ const findDefault = React.useCallback(() => {
64
+ if (initialVariantId) {
65
+ const found = variants.find((v) => v.id === initialVariantId);
66
+ if (found) return found;
67
+ }
68
+ return variants.find((v) => v.availableForSale) ?? variants[0] ?? null;
69
+ }, [initialVariantId, variants]);
70
+
71
+ const [selectedVariantId, setSelectedVariantId] = React.useState<string | null>(
72
+ findDefault()?.id ?? null,
73
+ );
74
+
75
+ React.useEffect(() => {
76
+ setSelectedVariantId(findDefault()?.id ?? null);
77
+ }, [findDefault]);
78
+
79
+ const selectedVariant =
80
+ variants.find((v) => v.id === selectedVariantId) ?? null;
81
+
82
+ const selectedOptions: Record<string, string> = React.useMemo(() => {
83
+ const m: Record<string, string> = {};
84
+ for (const so of selectedVariant?.selectedOptions ?? []) m[so.name] = so.value;
85
+ return m;
86
+ }, [selectedVariant]);
87
+
88
+ const options: ProductOptionItem[] = React.useMemo(() => {
89
+ const dimMap = new Map<string, Set<string>>();
90
+ // 收集所有 option 维度
91
+ if (product.options?.length) {
92
+ for (const opt of product.options) {
93
+ dimMap.set(opt.name, new Set(opt.values));
94
+ }
95
+ } else {
96
+ for (const v of variants) {
97
+ for (const so of v.selectedOptions ?? []) {
98
+ if (!dimMap.has(so.name)) dimMap.set(so.name, new Set());
99
+ dimMap.get(so.name)!.add(so.value);
100
+ }
101
+ }
102
+ }
103
+ return Array.from(dimMap.entries()).map(([name, vals]) => ({
104
+ name,
105
+ values: Array.from(vals).map((v) => ({
106
+ value: v,
107
+ available: variants.some(
108
+ (variant) =>
109
+ variant.availableForSale &&
110
+ variant.selectedOptions?.some((so) => so.name === name && so.value === v),
111
+ ),
112
+ isSelected: selectedOptions[name] === v,
113
+ })),
114
+ }));
115
+ }, [product, variants, selectedOptions]);
116
+
117
+ const setOptionValue = React.useCallback(
118
+ (name: string, value: string) => {
119
+ const next = { ...selectedOptions, [name]: value };
120
+ // 找匹配 variant
121
+ const target = variants.find((v) =>
122
+ Object.entries(next).every(([n, val]) =>
123
+ v.selectedOptions?.some((so) => so.name === n && so.value === val),
124
+ ),
125
+ );
126
+ if (target) setSelectedVariantId(target.id);
127
+ },
128
+ [selectedOptions, variants],
129
+ );
130
+
131
+ const setVariantById = React.useCallback((id: string) => setSelectedVariantId(id), []);
132
+
133
+ const value: ProductOptionsContextValue = {
134
+ product,
135
+ selectedVariant,
136
+ selectedOptions,
137
+ options,
138
+ setOptionValue,
139
+ setVariantById,
140
+ };
141
+
142
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
143
+ }
144
+
145
+ export function useProductOptions(): ProductOptionsContextValue {
146
+ const v = React.useContext(Ctx);
147
+ if (!v) throw new Error('useProductOptions must be used inside <ProductOptionsProvider>');
148
+ return v;
149
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * <ProductPrice> — 商品价格
3
+ *
4
+ * 对齐 @shopify/hydrogen <ProductPrice>:
5
+ * - 渲染当前价
6
+ * - 当 compareAtPrice > price 时自动显示划线对比价
7
+ * - 支持 unit price 模式
8
+ * - 可定制 className / 容器
9
+ *
10
+ * 用法:
11
+ * <ProductPrice price={{ amount: '99', currencyCode: 'CNY' }} />
12
+ * <ProductPrice price={...} compareAtPrice={{ amount: '129', ... }} />
13
+ */
14
+
15
+ import * as React from 'react';
16
+ import { Money, type MoneyData, type MoneyMeasurement } from './Money';
17
+
18
+ export interface ProductPriceProps extends React.HTMLAttributes<HTMLElement> {
19
+ price: MoneyData;
20
+ compareAtPrice?: MoneyData | null;
21
+ /** unit price,例如每 100g */
22
+ measurement?: MoneyMeasurement;
23
+ /** locale 透传 */
24
+ locale?: string;
25
+ /** 划线价的额外 className */
26
+ compareAtClassName?: string;
27
+ as?: keyof JSX.IntrinsicElements;
28
+ }
29
+
30
+ export function ProductPrice(props: ProductPriceProps) {
31
+ const {
32
+ price,
33
+ compareAtPrice,
34
+ measurement,
35
+ locale,
36
+ compareAtClassName,
37
+ as,
38
+ ...rest
39
+ } = props;
40
+
41
+ const Tag: any = as || 'div';
42
+ const hasDiscount =
43
+ compareAtPrice &&
44
+ Number(compareAtPrice.amount) > Number(price.amount);
45
+
46
+ return (
47
+ <Tag {...rest} data-product-price>
48
+ <Money data={price} locale={locale} measurement={measurement} />
49
+ {hasDiscount && compareAtPrice && (
50
+ <Money
51
+ data={compareAtPrice}
52
+ locale={locale}
53
+ as="s"
54
+ className={compareAtClassName}
55
+ data-compare-at="true"
56
+ style={{ marginLeft: '0.5em', opacity: 0.6 }}
57
+ />
58
+ )}
59
+ </Tag>
60
+ );
61
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * <ShopProvider> + useShop()
3
+ *
4
+ * 全局店铺上下文 — 注入 storefront token / apiUrl / 货币 / locale / shop 元信息。
5
+ * 子组件用 useShop() 直接拿,不用 prop-drill。
6
+ *
7
+ * 用法:
8
+ * <ShopProvider
9
+ * storeId="store_xxx"
10
+ * storefrontAccessToken="shpat_pub_..."
11
+ * apiUrl="https://api.example.com/api/2026-04/graphql.json"
12
+ * shopName="Shopflare"
13
+ * currencyCode="CNY"
14
+ * locale="zh-CN"
15
+ * >
16
+ * <App />
17
+ * </ShopProvider>
18
+ *
19
+ * // 任意子组件:
20
+ * const { storeId, storefrontAccessToken } = useShop();
21
+ */
22
+
23
+ import * as React from 'react';
24
+
25
+ export interface ShopContextValue {
26
+ storeId: string;
27
+ storefrontAccessToken: string;
28
+ apiUrl: string;
29
+ shopName: string;
30
+ currencyCode: string;
31
+ locale: string;
32
+ /** 额外自定义字段,用户可塞任意业务 meta */
33
+ meta?: Record<string, any>;
34
+ }
35
+
36
+ const ShopContext = React.createContext<ShopContextValue | null>(null);
37
+
38
+ export interface ShopProviderProps extends Partial<ShopContextValue> {
39
+ storeId: string;
40
+ storefrontAccessToken: string;
41
+ apiUrl: string;
42
+ children: React.ReactNode;
43
+ }
44
+
45
+ export function ShopProvider(props: ShopProviderProps) {
46
+ const value = React.useMemo<ShopContextValue>(
47
+ () => ({
48
+ storeId: props.storeId,
49
+ storefrontAccessToken: props.storefrontAccessToken,
50
+ apiUrl: props.apiUrl,
51
+ shopName: props.shopName ?? '',
52
+ currencyCode: props.currencyCode ?? 'USD',
53
+ locale: props.locale ?? 'en-US',
54
+ meta: props.meta,
55
+ }),
56
+ [
57
+ props.storeId,
58
+ props.storefrontAccessToken,
59
+ props.apiUrl,
60
+ props.shopName,
61
+ props.currencyCode,
62
+ props.locale,
63
+ props.meta,
64
+ ],
65
+ );
66
+ return <ShopContext.Provider value={value}>{props.children}</ShopContext.Provider>;
67
+ }
68
+
69
+ /**
70
+ * 拿当前 shop context。Provider 外调用会 throw。
71
+ */
72
+ export function useShop(): ShopContextValue {
73
+ const v = React.useContext(ShopContext);
74
+ if (!v) {
75
+ throw new Error('useShop must be used inside <ShopProvider>');
76
+ }
77
+ return v;
78
+ }
79
+
80
+ /**
81
+ * 非 throw 版本:Provider 外返回 null。
82
+ * 给组件做"可选 fallback"用,比如 <Money> 在没 ShopProvider 时也要能跑。
83
+ */
84
+ export function useShopOptional(): ShopContextValue | null {
85
+ return React.useContext(ShopContext);
86
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * <VariantSelector> — 商品 variant 选择器
3
+ *
4
+ * 对齐 @shopify/hydrogen <VariantSelector>:
5
+ * - 自动解析 variants 的 selectedOptions 维度(颜色 / 尺寸 等)
6
+ * - 渲染每个维度的选项按钮
7
+ * - 不可购买组合 disabled
8
+ * - URL 同步(可选)
9
+ * - 默认选第一个 availableForSale
10
+ *
11
+ * 设计:组件用 render prop,把每个 option 的选项交给商家渲染。
12
+ *
13
+ * 用法:
14
+ * <VariantSelector
15
+ * product={product}
16
+ * value={selectedVariantId}
17
+ * onChange={setSelectedVariantId}
18
+ * >
19
+ * {({ option, value, onSelect }) => (
20
+ * <div>
21
+ * <h4>{option.name}</h4>
22
+ * {option.values.map((v) => (
23
+ * <button
24
+ * key={v.value}
25
+ * disabled={!v.available}
26
+ * onClick={() => onSelect(v.value)}
27
+ * aria-pressed={v.value === value}
28
+ * >
29
+ * {v.value}
30
+ * </button>
31
+ * ))}
32
+ * </div>
33
+ * )}
34
+ * </VariantSelector>
35
+ */
36
+
37
+ import * as React from 'react';
38
+
39
+ interface SelectedOption {
40
+ name: string;
41
+ value: string;
42
+ }
43
+
44
+ interface ProductVariant {
45
+ id: string;
46
+ availableForSale: boolean;
47
+ selectedOptions?: SelectedOption[];
48
+ }
49
+
50
+ interface ProductLike {
51
+ options?: Array<{ name: string; values: string[] }>;
52
+ variants: { nodes: ProductVariant[] };
53
+ }
54
+
55
+ export interface VariantOption {
56
+ name: string;
57
+ values: Array<{ value: string; available: boolean }>;
58
+ }
59
+
60
+ export interface VariantSelectorRenderProps {
61
+ option: VariantOption;
62
+ /** 当前已选择的 value(在这个 option 维度上) */
63
+ value: string | undefined;
64
+ /** 选择 value,触发 onChange 更新 selectedVariantId */
65
+ onSelect: (value: string) => void;
66
+ }
67
+
68
+ export interface VariantSelectorProps {
69
+ product: ProductLike;
70
+ /** 当前选中 variant 的 GID */
71
+ value: string;
72
+ /** variant 切换时调用 */
73
+ onChange?: (variantId: string) => void;
74
+ /** 每个 option 的渲染函数 */
75
+ children: (renderProps: VariantSelectorRenderProps) => React.ReactNode;
76
+ }
77
+
78
+ export function VariantSelector(props: VariantSelectorProps) {
79
+ const { product, value, onChange, children } = props;
80
+
81
+ const variants = product.variants.nodes;
82
+ const currentVariant = variants.find((v) => v.id === value) || variants[0];
83
+
84
+ // 推断 options(如果 product.options 没传,从 variants.selectedOptions 提取)
85
+ const options: VariantOption[] = React.useMemo(() => {
86
+ if (product.options && product.options.length > 0) {
87
+ return product.options.map((opt) => ({
88
+ name: opt.name,
89
+ values: opt.values.map((v) => ({
90
+ value: v,
91
+ available: variants.some(
92
+ (variant) =>
93
+ variant.availableForSale &&
94
+ variant.selectedOptions?.some((so) => so.name === opt.name && so.value === v),
95
+ ),
96
+ })),
97
+ }));
98
+ }
99
+ // 从 variants.selectedOptions 反推
100
+ const dimMap = new Map<string, Set<string>>();
101
+ for (const v of variants) {
102
+ for (const so of v.selectedOptions ?? []) {
103
+ if (!dimMap.has(so.name)) dimMap.set(so.name, new Set());
104
+ dimMap.get(so.name)!.add(so.value);
105
+ }
106
+ }
107
+ return Array.from(dimMap.entries()).map(([name, values]) => ({
108
+ name,
109
+ values: Array.from(values).map((v) => ({
110
+ value: v,
111
+ available: variants.some(
112
+ (variant) =>
113
+ variant.availableForSale &&
114
+ variant.selectedOptions?.some((so) => so.name === name && so.value === v),
115
+ ),
116
+ })),
117
+ }));
118
+ }, [product, variants]);
119
+
120
+ // 切换某 option 的 value:找一个匹配的 variant
121
+ const handleSelect = (optionName: string, optionValue: string) => {
122
+ const currentSelected = new Map<string, string>(
123
+ (currentVariant?.selectedOptions ?? []).map((so) => [so.name, so.value]),
124
+ );
125
+ currentSelected.set(optionName, optionValue);
126
+ // 找最匹配 + available 的 variant
127
+ const target = variants.find((v) =>
128
+ Array.from(currentSelected.entries()).every(([n, val]) =>
129
+ v.selectedOptions?.some((so) => so.name === n && so.value === val),
130
+ ),
131
+ );
132
+ if (target) onChange?.(target.id);
133
+ };
134
+
135
+ return (
136
+ <>
137
+ {options.map((option) => (
138
+ <React.Fragment key={option.name}>
139
+ {children({
140
+ option,
141
+ value: currentVariant?.selectedOptions?.find((so) => so.name === option.name)?.value,
142
+ onSelect: (val) => handleSelect(option.name, val),
143
+ })}
144
+ </React.Fragment>
145
+ ))}
146
+ </>
147
+ );
148
+ }