@shopbb/helium 0.3.0-alpha.1 → 0.3.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.
- package/dist/components/AddToCartButton.d.ts.map +1 -1
- package/dist/components/AddToCartButton.js +17 -5
- package/dist/components/AddToCartButton.js.map +1 -1
- package/dist/components/AnalyticsProvider.d.ts +106 -0
- package/dist/components/AnalyticsProvider.d.ts.map +1 -0
- package/dist/components/AnalyticsProvider.js +135 -0
- package/dist/components/AnalyticsProvider.js.map +1 -0
- package/dist/components/CartProvider.d.ts +111 -0
- package/dist/components/CartProvider.d.ts.map +1 -0
- package/dist/components/CartProvider.js +263 -0
- package/dist/components/CartProvider.js.map +1 -0
- package/dist/components/Money.d.ts.map +1 -1
- package/dist/components/Money.js +6 -1
- package/dist/components/Money.js.map +1 -1
- package/dist/components/ProductOptionsProvider.d.ts +70 -0
- package/dist/components/ProductOptionsProvider.d.ts.map +1 -0
- package/dist/components/ProductOptionsProvider.js +93 -0
- package/dist/components/ProductOptionsProvider.js.map +1 -0
- package/dist/components/ShopProvider.d.ts +49 -0
- package/dist/components/ShopProvider.d.ts.map +1 -0
- package/dist/components/ShopProvider.js +62 -0
- package/dist/components/ShopProvider.js.map +1 -0
- package/dist/components/index.d.ts +18 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +16 -1
- package/dist/components/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AddToCartButton.tsx +16 -5
- package/src/components/AnalyticsProvider.tsx +175 -0
- package/src/components/CartProvider.tsx +378 -0
- package/src/components/Money.tsx +7 -1
- package/src/components/ProductOptionsProvider.tsx +149 -0
- package/src/components/ShopProvider.tsx +86 -0
- package/src/components/index.ts +39 -1
package/dist/components/index.js
CHANGED
|
@@ -2,13 +2,28 @@
|
|
|
2
2
|
* @shopbb/helium/components — 商家用 React 组件
|
|
3
3
|
*
|
|
4
4
|
* 用法:
|
|
5
|
-
* import {
|
|
5
|
+
* import {
|
|
6
|
+
* ShopProvider, CartProvider, AnalyticsProvider,
|
|
7
|
+
* useShop, useCart, useAnalytics,
|
|
8
|
+
* Money, Image, ProductPrice,
|
|
9
|
+
* AddToCartButton, CartLineQuantityAdjustButton,
|
|
10
|
+
* VariantSelector, ProductOptionsProvider, useProductOptions,
|
|
11
|
+
* Analytics,
|
|
12
|
+
* } from '@shopbb/helium/components';
|
|
6
13
|
*
|
|
7
14
|
* 设计原则:
|
|
8
15
|
* - 无样式:组件只管行为 + 语义化 DOM,样式商家自己写
|
|
9
16
|
* - data-* 钩子:方便选择器
|
|
10
17
|
* - 对齐 Shopify Hydrogen 同名组件的 API,迁移成本低
|
|
18
|
+
* - Provider 链:<ShopProvider> > <CartProvider> > <AnalyticsProvider> > App
|
|
19
|
+
* 组件优先从 Provider 拿;Provider 缺省时回退到 props 或 noop
|
|
11
20
|
*/
|
|
21
|
+
// Providers + hooks
|
|
22
|
+
export { ShopProvider, useShop, useShopOptional } from './ShopProvider';
|
|
23
|
+
export { CartProvider, useCart, useCartOptional } from './CartProvider';
|
|
24
|
+
export { ProductOptionsProvider, useProductOptions, } from './ProductOptionsProvider';
|
|
25
|
+
export { AnalyticsProvider, useAnalytics, Analytics } from './AnalyticsProvider';
|
|
26
|
+
// 展示型组件
|
|
12
27
|
export { Money } from './Money';
|
|
13
28
|
export { Image } from './Image';
|
|
14
29
|
export { ProductPrice } from './ProductPrice';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,oBAAoB;AACpB,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAGxE,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAWxE,OAAO,EACL,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,0BAA0B,CAAC;AAOlC,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAGjF,QAAQ;AACR,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAGhC,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAGhC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAG9C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAGpD,OAAO,EAAE,4BAA4B,EAAE,MAAM,gCAAgC,CAAC;AAG9E,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
|
package/package.json
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import * as React from 'react';
|
|
25
|
+
import { useCartOptional } from './CartProvider';
|
|
26
|
+
import { useAnalytics } from './AnalyticsProvider';
|
|
25
27
|
|
|
26
28
|
export interface AddToCartButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'onError'> {
|
|
27
29
|
/** 必传:variant 的 GID(gid://shopbb/ProductVariant/...) */
|
|
@@ -55,17 +57,26 @@ export function AddToCartButton(props: AddToCartButtonProps) {
|
|
|
55
57
|
} = props;
|
|
56
58
|
|
|
57
59
|
const [adding, setAdding] = React.useState(false);
|
|
60
|
+
const cart = useCartOptional();
|
|
61
|
+
const analytics = useAnalytics();
|
|
58
62
|
|
|
59
63
|
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
60
64
|
e.preventDefault();
|
|
61
65
|
if (adding || disabledProp) return;
|
|
62
|
-
|
|
63
|
-
console.warn('[AddToCartButton] onAdd not provided; this button does nothing in Phase 1');
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
+
|
|
66
67
|
setAdding(true);
|
|
67
68
|
try {
|
|
68
|
-
|
|
69
|
+
if (onAdd) {
|
|
70
|
+
// 商家显式传了 onAdd 优先用(Phase 1 兼容路径)
|
|
71
|
+
await onAdd(variantId, quantity);
|
|
72
|
+
} else if (cart) {
|
|
73
|
+
// 自动从 <CartProvider> 取
|
|
74
|
+
await cart.linesAdd([{ merchandiseId: variantId, quantity }]);
|
|
75
|
+
} else {
|
|
76
|
+
console.warn('[AddToCartButton] no onAdd and no <CartProvider> — button does nothing');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
analytics.emit('add_to_cart', { variantId, quantity });
|
|
69
80
|
onAdded?.();
|
|
70
81
|
} catch (err: any) {
|
|
71
82
|
console.error('[AddToCartButton] add failed:', err);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AnalyticsProvider> + useAnalytics() + <Analytics.*> 事件组件
|
|
3
|
+
*
|
|
4
|
+
* 简单事件总线:
|
|
5
|
+
* - 商家注册 reporters(GA / Plausible / 自家 API)
|
|
6
|
+
* - 通过 <Analytics.PageView /> / <Analytics.ProductView /> 等组件声明式触发
|
|
7
|
+
* - 也可以 useAnalytics().emit(name, data)
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* <AnalyticsProvider
|
|
11
|
+
* onEvent={(e) => {
|
|
12
|
+
* fetch('/api/analytics', { method: 'POST', body: JSON.stringify(e) });
|
|
13
|
+
* window.dataLayer?.push(e);
|
|
14
|
+
* }}
|
|
15
|
+
* >
|
|
16
|
+
* <App />
|
|
17
|
+
* </AnalyticsProvider>
|
|
18
|
+
*
|
|
19
|
+
* // 在某页:
|
|
20
|
+
* <Analytics.PageView path="/products" title="Products" />
|
|
21
|
+
* <Analytics.ProductView product={product} />
|
|
22
|
+
* <Analytics.AddToCart cart={cart} addedLineId={lineId} />
|
|
23
|
+
*
|
|
24
|
+
* // 命令式:
|
|
25
|
+
* const { emit } = useAnalytics();
|
|
26
|
+
* emit('search', { query: 'wireless' });
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as React from 'react';
|
|
30
|
+
import { useCartOptional } from './CartProvider';
|
|
31
|
+
import { useShopOptional } from './ShopProvider';
|
|
32
|
+
|
|
33
|
+
export interface AnalyticsEvent {
|
|
34
|
+
/** 事件名,例如 'page_view' / 'product_view' / 'add_to_cart' */
|
|
35
|
+
name: string;
|
|
36
|
+
/** 时间戳 (ms) */
|
|
37
|
+
timestamp: number;
|
|
38
|
+
/** 当前 shop(如果在 ShopProvider 内) */
|
|
39
|
+
shop?: { storeId: string; shopName: string } | null;
|
|
40
|
+
/** 自定义 payload */
|
|
41
|
+
payload: Record<string, any>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AnalyticsContextValue {
|
|
45
|
+
emit: (name: string, payload?: Record<string, any>) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const Ctx = React.createContext<AnalyticsContextValue | null>(null);
|
|
49
|
+
|
|
50
|
+
export interface AnalyticsProviderProps {
|
|
51
|
+
children: React.ReactNode;
|
|
52
|
+
/** 接收所有事件的回调 */
|
|
53
|
+
onEvent?: (event: AnalyticsEvent) => void;
|
|
54
|
+
/** 是否自动监听 cart 变化触发 cart_updated 事件 */
|
|
55
|
+
trackCart?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function AnalyticsProvider(props: AnalyticsProviderProps) {
|
|
59
|
+
const { children, onEvent, trackCart = true } = props;
|
|
60
|
+
const shop = useShopOptional();
|
|
61
|
+
const cartCtx = useCartOptional();
|
|
62
|
+
const onEventRef = React.useRef(onEvent);
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
onEventRef.current = onEvent;
|
|
65
|
+
}, [onEvent]);
|
|
66
|
+
|
|
67
|
+
const emit = React.useCallback(
|
|
68
|
+
(name: string, payload: Record<string, any> = {}) => {
|
|
69
|
+
onEventRef.current?.({
|
|
70
|
+
name,
|
|
71
|
+
timestamp: Date.now(),
|
|
72
|
+
shop: shop ? { storeId: shop.storeId, shopName: shop.shopName } : null,
|
|
73
|
+
payload,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
[shop],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// cart 自动追踪
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
if (!trackCart || !cartCtx) return;
|
|
82
|
+
return cartCtx.subscribe((c) => {
|
|
83
|
+
emit('cart_updated', {
|
|
84
|
+
cartId: c?.id,
|
|
85
|
+
totalQuantity: c?.totalQuantity,
|
|
86
|
+
totalAmount: c?.cost.totalAmount,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}, [trackCart, cartCtx, emit]);
|
|
90
|
+
|
|
91
|
+
return <Ctx.Provider value={{ emit }}>{children}</Ctx.Provider>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function useAnalytics(): AnalyticsContextValue {
|
|
95
|
+
const v = React.useContext(Ctx);
|
|
96
|
+
if (!v) {
|
|
97
|
+
// 没 Provider 时返回 noop(不 throw,便于组件可选嵌入)
|
|
98
|
+
return { emit: () => {} };
|
|
99
|
+
}
|
|
100
|
+
return v;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================
|
|
104
|
+
// 声明式事件组件
|
|
105
|
+
// ============================================================
|
|
106
|
+
|
|
107
|
+
function useEmitOnMount(name: string, payload: Record<string, any>) {
|
|
108
|
+
const { emit } = useAnalytics();
|
|
109
|
+
const payloadRef = React.useRef(payload);
|
|
110
|
+
payloadRef.current = payload;
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
emit(name, payloadRef.current);
|
|
113
|
+
// 只 mount 时触发一次(路由切换会重新 mount)
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, []);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function PageView(props: { path?: string; title?: string; [key: string]: any }) {
|
|
120
|
+
return useEmitOnMount('page_view', props);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ProductView(props: { product: { id: string; handle?: string; title?: string }; [key: string]: any }) {
|
|
124
|
+
const { product, ...rest } = props;
|
|
125
|
+
return useEmitOnMount('product_view', {
|
|
126
|
+
...rest,
|
|
127
|
+
productId: product.id,
|
|
128
|
+
productHandle: product.handle,
|
|
129
|
+
productTitle: product.title,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function CollectionView(props: { collection: { id: string; handle?: string }; [key: string]: any }) {
|
|
134
|
+
const { collection, ...rest } = props;
|
|
135
|
+
return useEmitOnMount('collection_view', {
|
|
136
|
+
...rest,
|
|
137
|
+
collectionId: collection.id,
|
|
138
|
+
collectionHandle: collection.handle,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function SearchView(props: { query: string; results?: number }) {
|
|
143
|
+
return useEmitOnMount('search', { query: props.query, results: props.results });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function AddToCart(props: { variantId: string; quantity?: number; [key: string]: any }) {
|
|
147
|
+
const { variantId, quantity, ...rest } = props;
|
|
148
|
+
return useEmitOnMount('add_to_cart', {
|
|
149
|
+
...rest,
|
|
150
|
+
variantId,
|
|
151
|
+
quantity: quantity ?? 1,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function CheckoutStart(props: { cart?: { id: string; totalQuantity?: number } }) {
|
|
156
|
+
return useEmitOnMount('checkout_start', {
|
|
157
|
+
cartId: props.cart?.id,
|
|
158
|
+
totalQuantity: props.cart?.totalQuantity,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Purchase(props: { orderId: string; total: number; currencyCode: string; [key: string]: any }) {
|
|
163
|
+
return useEmitOnMount('purchase', props);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 命名空间导出,让用户写 <Analytics.PageView /> */
|
|
167
|
+
export const Analytics = {
|
|
168
|
+
PageView,
|
|
169
|
+
ProductView,
|
|
170
|
+
CollectionView,
|
|
171
|
+
SearchView,
|
|
172
|
+
AddToCart,
|
|
173
|
+
CheckoutStart,
|
|
174
|
+
Purchase,
|
|
175
|
+
};
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CartProvider> + useCart()
|
|
3
|
+
*
|
|
4
|
+
* 接管 cart 全生命周期:
|
|
5
|
+
* - 自动从 cookie 读取 cart_id 并 fetch
|
|
6
|
+
* - 不存在则懒创建
|
|
7
|
+
* - linesAdd / linesUpdate / linesRemove 全部带 optimistic update + 失败回滚
|
|
8
|
+
* - subscribe(callback) 给外部(如 Analytics)订阅 cart 变化
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* <ShopProvider {...}>
|
|
12
|
+
* <CartProvider>
|
|
13
|
+
* <App />
|
|
14
|
+
* </CartProvider>
|
|
15
|
+
* </ShopProvider>
|
|
16
|
+
*
|
|
17
|
+
* const { cart, status, linesAdd, linesUpdate, linesRemove } = useCart();
|
|
18
|
+
*
|
|
19
|
+
* 注意:必须套在 <ShopProvider> 内(依赖它的 storefrontAccessToken + apiUrl)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
import { useShop } from './ShopProvider';
|
|
24
|
+
|
|
25
|
+
// ============================================================
|
|
26
|
+
// Types
|
|
27
|
+
// ============================================================
|
|
28
|
+
|
|
29
|
+
export interface CartLineMerchandise {
|
|
30
|
+
id: string;
|
|
31
|
+
title?: string;
|
|
32
|
+
image?: { url: string; altText?: string | null; width?: number | null; height?: number | null };
|
|
33
|
+
price?: { amount: string; currencyCode: string };
|
|
34
|
+
product?: { title: string; handle: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CartLine {
|
|
38
|
+
id: string;
|
|
39
|
+
quantity: number;
|
|
40
|
+
merchandise: CartLineMerchandise;
|
|
41
|
+
cost: { totalAmount: { amount: string; currencyCode: string } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Cart {
|
|
45
|
+
id: string;
|
|
46
|
+
totalQuantity: number;
|
|
47
|
+
checkoutUrl: string;
|
|
48
|
+
lines: { nodes: CartLine[] };
|
|
49
|
+
cost: {
|
|
50
|
+
subtotalAmount: { amount: string; currencyCode: string };
|
|
51
|
+
totalAmount: { amount: string; currencyCode: string };
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type CartStatus = 'uninitialized' | 'loading' | 'idle' | 'updating' | 'error';
|
|
56
|
+
|
|
57
|
+
export interface CartUserError {
|
|
58
|
+
field?: string[] | null;
|
|
59
|
+
message: string;
|
|
60
|
+
code?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CartContextValue {
|
|
64
|
+
cart: Cart | null;
|
|
65
|
+
status: CartStatus;
|
|
66
|
+
error: string | null;
|
|
67
|
+
/** 加 line */
|
|
68
|
+
linesAdd: (lines: Array<{ merchandiseId: string; quantity?: number }>) => Promise<void>;
|
|
69
|
+
/** 更新数量(lineId, quantity);quantity<=0 等价 remove */
|
|
70
|
+
linesUpdate: (lines: Array<{ id: string; quantity: number }>) => Promise<void>;
|
|
71
|
+
/** 删 line */
|
|
72
|
+
linesRemove: (lineIds: string[]) => Promise<void>;
|
|
73
|
+
/** 重新从服务端拉 cart */
|
|
74
|
+
refetch: () => Promise<void>;
|
|
75
|
+
/** 订阅 cart 变化(Analytics 用) */
|
|
76
|
+
subscribe: (listener: (cart: Cart | null) => void) => () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const CartContext = React.createContext<CartContextValue | null>(null);
|
|
80
|
+
|
|
81
|
+
// ============================================================
|
|
82
|
+
// GraphQL
|
|
83
|
+
// ============================================================
|
|
84
|
+
|
|
85
|
+
const CART_FRAGMENT = /* GraphQL */ `
|
|
86
|
+
fragment CartParts on Cart {
|
|
87
|
+
id totalQuantity checkoutUrl
|
|
88
|
+
lines(first: 100) {
|
|
89
|
+
nodes {
|
|
90
|
+
id quantity
|
|
91
|
+
merchandise {
|
|
92
|
+
... on ProductVariant {
|
|
93
|
+
id title
|
|
94
|
+
image { url altText width height }
|
|
95
|
+
price { amount currencyCode }
|
|
96
|
+
product { title handle }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
cost { totalAmount { amount currencyCode } }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
cost {
|
|
103
|
+
subtotalAmount { amount currencyCode }
|
|
104
|
+
totalAmount { amount currencyCode }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
const Q_GET_CART = CART_FRAGMENT + `
|
|
110
|
+
query GetCart($id: ID!) { cart(id: $id) { ...CartParts } }
|
|
111
|
+
`;
|
|
112
|
+
const M_CART_CREATE = CART_FRAGMENT + `
|
|
113
|
+
mutation Create($input: CartInput) {
|
|
114
|
+
cartCreate(input: $input) { cart { ...CartParts } userErrors { field message code } }
|
|
115
|
+
}
|
|
116
|
+
`;
|
|
117
|
+
const M_LINES_ADD = CART_FRAGMENT + `
|
|
118
|
+
mutation Add($cartId: ID!, $lines: [CartLineInput!]!) {
|
|
119
|
+
cartLinesAdd(cartId: $cartId, lines: $lines) { cart { ...CartParts } userErrors { field message code } }
|
|
120
|
+
}
|
|
121
|
+
`;
|
|
122
|
+
const M_LINES_UPDATE = CART_FRAGMENT + `
|
|
123
|
+
mutation Update($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
|
|
124
|
+
cartLinesUpdate(cartId: $cartId, lines: $lines) { cart { ...CartParts } userErrors { field message code } }
|
|
125
|
+
}
|
|
126
|
+
`;
|
|
127
|
+
const M_LINES_REMOVE = CART_FRAGMENT + `
|
|
128
|
+
mutation Remove($cartId: ID!, $lineIds: [ID!]!) {
|
|
129
|
+
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { cart { ...CartParts } userErrors { field message code } }
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
// ============================================================
|
|
134
|
+
// cookie cart id
|
|
135
|
+
// ============================================================
|
|
136
|
+
|
|
137
|
+
const COOKIE_NAME = 'cart';
|
|
138
|
+
|
|
139
|
+
function readCartIdFromCookie(): string | null {
|
|
140
|
+
if (typeof document === 'undefined') return null;
|
|
141
|
+
const m = document.cookie.match(new RegExp('(?:^|; )' + COOKIE_NAME + '=([^;]+)'));
|
|
142
|
+
if (!m) return null;
|
|
143
|
+
return `gid://shopbb/Cart/${decodeURIComponent(m[1])}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function saveCartIdToCookie(cartGid: string) {
|
|
147
|
+
if (typeof document === 'undefined') return;
|
|
148
|
+
const id = cartGid.replace(/^gid:\/\/shopbb\/Cart\//, '');
|
|
149
|
+
const oneYear = 365 * 24 * 60 * 60;
|
|
150
|
+
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(id)}; path=/; max-age=${oneYear}; SameSite=Lax`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================
|
|
154
|
+
// Provider
|
|
155
|
+
// ============================================================
|
|
156
|
+
|
|
157
|
+
export interface CartProviderProps {
|
|
158
|
+
children: React.ReactNode;
|
|
159
|
+
/** 加载完成后是否自动拉 cart。默认 true */
|
|
160
|
+
fetchOnMount?: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function CartProvider({ children, fetchOnMount = true }: CartProviderProps) {
|
|
164
|
+
const shop = useShop();
|
|
165
|
+
const [cart, setCart] = React.useState<Cart | null>(null);
|
|
166
|
+
const [status, setStatus] = React.useState<CartStatus>('uninitialized');
|
|
167
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
168
|
+
const listenersRef = React.useRef(new Set<(c: Cart | null) => void>());
|
|
169
|
+
|
|
170
|
+
const gql = React.useCallback(
|
|
171
|
+
async <T = any>(query: string, variables?: any): Promise<T> => {
|
|
172
|
+
const res = await fetch(shop.apiUrl, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
'X-Storefront-Access-Token': shop.storefrontAccessToken,
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify({ query, variables }),
|
|
179
|
+
credentials: 'include',
|
|
180
|
+
});
|
|
181
|
+
const json: any = await res.json();
|
|
182
|
+
if (json.errors) throw new Error(json.errors[0]?.message || 'GraphQL error');
|
|
183
|
+
return json.data;
|
|
184
|
+
},
|
|
185
|
+
[shop.apiUrl, shop.storefrontAccessToken],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const notifyListeners = React.useCallback((c: Cart | null) => {
|
|
189
|
+
for (const fn of listenersRef.current) fn(c);
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
// 应用新 cart 数据(state + 订阅者)
|
|
193
|
+
const applyCart = React.useCallback(
|
|
194
|
+
(next: Cart | null) => {
|
|
195
|
+
setCart(next);
|
|
196
|
+
notifyListeners(next);
|
|
197
|
+
},
|
|
198
|
+
[notifyListeners],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// 保证有 cart_id,没有就创建
|
|
202
|
+
const ensureCartId = React.useCallback(async (): Promise<string> => {
|
|
203
|
+
const cookieCartId = readCartIdFromCookie();
|
|
204
|
+
if (cookieCartId) return cookieCartId;
|
|
205
|
+
const data = await gql<{ cartCreate: { cart: Cart; userErrors: CartUserError[] } }>(
|
|
206
|
+
M_CART_CREATE,
|
|
207
|
+
{ input: { lines: [] } },
|
|
208
|
+
);
|
|
209
|
+
if (data.cartCreate.userErrors.length > 0) {
|
|
210
|
+
throw new Error(data.cartCreate.userErrors[0].message);
|
|
211
|
+
}
|
|
212
|
+
const newCart = data.cartCreate.cart;
|
|
213
|
+
saveCartIdToCookie(newCart.id);
|
|
214
|
+
applyCart(newCart);
|
|
215
|
+
return newCart.id;
|
|
216
|
+
}, [gql, applyCart]);
|
|
217
|
+
|
|
218
|
+
// 初始拉取
|
|
219
|
+
const refetch = React.useCallback(async () => {
|
|
220
|
+
setStatus('loading');
|
|
221
|
+
try {
|
|
222
|
+
const cookieCartId = readCartIdFromCookie();
|
|
223
|
+
if (!cookieCartId) {
|
|
224
|
+
applyCart(null);
|
|
225
|
+
setStatus('idle');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const data = await gql<{ cart: Cart | null }>(Q_GET_CART, { id: cookieCartId });
|
|
229
|
+
applyCart(data.cart);
|
|
230
|
+
setStatus('idle');
|
|
231
|
+
} catch (err: any) {
|
|
232
|
+
setError(err?.message ?? String(err));
|
|
233
|
+
setStatus('error');
|
|
234
|
+
}
|
|
235
|
+
}, [gql, applyCart]);
|
|
236
|
+
|
|
237
|
+
React.useEffect(() => {
|
|
238
|
+
if (fetchOnMount && status === 'uninitialized') {
|
|
239
|
+
void refetch();
|
|
240
|
+
}
|
|
241
|
+
}, [fetchOnMount, status, refetch]);
|
|
242
|
+
|
|
243
|
+
// optimistic 包装
|
|
244
|
+
const runMutation = React.useCallback(
|
|
245
|
+
async <T,>(optimisticCart: Cart | null, mutator: () => Promise<{ cart: Cart; userErrors: CartUserError[] }>) => {
|
|
246
|
+
const prev = cart;
|
|
247
|
+
if (optimisticCart) applyCart(optimisticCart);
|
|
248
|
+
setStatus('updating');
|
|
249
|
+
try {
|
|
250
|
+
const result = await mutator();
|
|
251
|
+
if (result.userErrors && result.userErrors.length > 0) {
|
|
252
|
+
throw new Error(result.userErrors[0].message);
|
|
253
|
+
}
|
|
254
|
+
applyCart(result.cart);
|
|
255
|
+
setStatus('idle');
|
|
256
|
+
setError(null);
|
|
257
|
+
} catch (err: any) {
|
|
258
|
+
applyCart(prev); // 回滚
|
|
259
|
+
setError(err?.message ?? String(err));
|
|
260
|
+
setStatus('error');
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
[cart, applyCart],
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const linesAdd = React.useCallback(
|
|
268
|
+
async (lines: Array<{ merchandiseId: string; quantity?: number }>) => {
|
|
269
|
+
const cartId = await ensureCartId();
|
|
270
|
+
await runMutation(null, async () => {
|
|
271
|
+
const data = await gql<{ cartLinesAdd: { cart: Cart; userErrors: CartUserError[] } }>(
|
|
272
|
+
M_LINES_ADD,
|
|
273
|
+
{
|
|
274
|
+
cartId,
|
|
275
|
+
lines: lines.map((l) => ({ merchandiseId: l.merchandiseId, quantity: l.quantity ?? 1 })),
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
return data.cartLinesAdd;
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
[ensureCartId, runMutation, gql],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const linesUpdate = React.useCallback(
|
|
285
|
+
async (lines: Array<{ id: string; quantity: number }>) => {
|
|
286
|
+
// remove 走 remove
|
|
287
|
+
const toRemove = lines.filter((l) => l.quantity <= 0).map((l) => l.id);
|
|
288
|
+
const toUpdate = lines.filter((l) => l.quantity > 0);
|
|
289
|
+
|
|
290
|
+
// optimistic: 改 quantity
|
|
291
|
+
const optimistic =
|
|
292
|
+
cart != null
|
|
293
|
+
? {
|
|
294
|
+
...cart,
|
|
295
|
+
lines: {
|
|
296
|
+
...cart.lines,
|
|
297
|
+
nodes: cart.lines.nodes
|
|
298
|
+
.map((ln) => {
|
|
299
|
+
const upd = toUpdate.find((l) => l.id === ln.id);
|
|
300
|
+
return upd ? { ...ln, quantity: upd.quantity } : ln;
|
|
301
|
+
})
|
|
302
|
+
.filter((ln) => !toRemove.includes(ln.id)),
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
: null;
|
|
306
|
+
|
|
307
|
+
const cartId = await ensureCartId();
|
|
308
|
+
await runMutation(optimistic, async () => {
|
|
309
|
+
if (toRemove.length > 0) {
|
|
310
|
+
await gql(M_LINES_REMOVE, { cartId, lineIds: toRemove });
|
|
311
|
+
}
|
|
312
|
+
if (toUpdate.length === 0) {
|
|
313
|
+
const data = await gql<{ cart: Cart }>(Q_GET_CART, { id: cartId });
|
|
314
|
+
return { cart: data.cart!, userErrors: [] };
|
|
315
|
+
}
|
|
316
|
+
const data = await gql<{ cartLinesUpdate: { cart: Cart; userErrors: CartUserError[] } }>(
|
|
317
|
+
M_LINES_UPDATE,
|
|
318
|
+
{ cartId, lines: toUpdate },
|
|
319
|
+
);
|
|
320
|
+
return data.cartLinesUpdate;
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
[cart, ensureCartId, runMutation, gql],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const linesRemove = React.useCallback(
|
|
327
|
+
async (lineIds: string[]) => {
|
|
328
|
+
const optimistic =
|
|
329
|
+
cart != null
|
|
330
|
+
? {
|
|
331
|
+
...cart,
|
|
332
|
+
lines: { ...cart.lines, nodes: cart.lines.nodes.filter((l) => !lineIds.includes(l.id)) },
|
|
333
|
+
}
|
|
334
|
+
: null;
|
|
335
|
+
|
|
336
|
+
const cartId = await ensureCartId();
|
|
337
|
+
await runMutation(optimistic, async () => {
|
|
338
|
+
const data = await gql<{ cartLinesRemove: { cart: Cart; userErrors: CartUserError[] } }>(
|
|
339
|
+
M_LINES_REMOVE,
|
|
340
|
+
{ cartId, lineIds },
|
|
341
|
+
);
|
|
342
|
+
return data.cartLinesRemove;
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
[cart, ensureCartId, runMutation, gql],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const subscribe = React.useCallback((listener: (c: Cart | null) => void) => {
|
|
349
|
+
listenersRef.current.add(listener);
|
|
350
|
+
return () => {
|
|
351
|
+
listenersRef.current.delete(listener);
|
|
352
|
+
};
|
|
353
|
+
}, []);
|
|
354
|
+
|
|
355
|
+
const value: CartContextValue = React.useMemo(
|
|
356
|
+
() => ({ cart, status, error, linesAdd, linesUpdate, linesRemove, refetch, subscribe }),
|
|
357
|
+
[cart, status, error, linesAdd, linesUpdate, linesRemove, refetch, subscribe],
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 拿 cart context。Provider 外 throw。
|
|
365
|
+
*/
|
|
366
|
+
export function useCart(): CartContextValue {
|
|
367
|
+
const v = React.useContext(CartContext);
|
|
368
|
+
if (!v) throw new Error('useCart must be used inside <CartProvider>');
|
|
369
|
+
return v;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 非 throw 版本:Provider 外返回 null。
|
|
374
|
+
* 给可选 fallback 用,比如 <AddToCartButton> 在没 Provider 时退化到 props.onAdd。
|
|
375
|
+
*/
|
|
376
|
+
export function useCartOptional(): CartContextValue | null {
|
|
377
|
+
return React.useContext(CartContext);
|
|
378
|
+
}
|
package/src/components/Money.tsx
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import * as React from 'react';
|
|
17
|
+
import { useShopOptional } from './ShopProvider';
|
|
17
18
|
|
|
18
19
|
export interface MoneyData {
|
|
19
20
|
amount: string;
|
|
@@ -64,7 +65,12 @@ export function Money(props: MoneyProps) {
|
|
|
64
65
|
} = props;
|
|
65
66
|
|
|
66
67
|
const Tag: any = as || 'span';
|
|
67
|
-
const
|
|
68
|
+
const shop = useShopOptional();
|
|
69
|
+
const locale =
|
|
70
|
+
localeProp ||
|
|
71
|
+
shop?.locale ||
|
|
72
|
+
DEFAULT_LOCALE_BY_CURRENCY[data.currencyCode] ||
|
|
73
|
+
'en-US';
|
|
68
74
|
const amount = Number(data.amount);
|
|
69
75
|
|
|
70
76
|
let formatted: string;
|