@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,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,119 @@
1
+ /**
2
+ * <CartLineQuantityAdjustButton> — cart 行数量加减按钮
3
+ *
4
+ * 对齐 @shopify/hydrogen <CartLineQuantityAdjustButton>:
5
+ * - +/- 按钮调用 onChange
6
+ * - 防抖(300ms)避免点快导致多次请求
7
+ * - min/max 校验
8
+ * - 移除时弹确认(可选)
9
+ *
10
+ * 用法:
11
+ * const { updateLine, removeLine } = useCart();
12
+ * <CartLineQuantityAdjustButton
13
+ * line={line}
14
+ * onUpdate={(newQty) => updateLine(line.id, newQty)}
15
+ * onRemove={() => removeLine(line.id)}
16
+ * />
17
+ */
18
+
19
+ import * as React from 'react';
20
+
21
+ export interface CartLineQuantityAdjustButtonProps {
22
+ /** line 的当前数量 */
23
+ quantity: number;
24
+ /** 最小值,默认 1(小于此值改成 remove) */
25
+ min?: number;
26
+ /** 最大值,默认 99 */
27
+ max?: number;
28
+ /** 数量变化回调 */
29
+ onUpdate?: (newQuantity: number) => Promise<void> | void;
30
+ /** 数量降到 0 时触发 remove */
31
+ onRemove?: () => Promise<void> | void;
32
+ /** 防抖延迟(ms),默认 300 */
33
+ debounceMs?: number;
34
+ /** 自定义包装容器 className */
35
+ className?: string;
36
+ /** 减号按钮 props 透传 */
37
+ decreaseProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
38
+ /** 加号按钮 props 透传 */
39
+ increaseProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
40
+ }
41
+
42
+ export function CartLineQuantityAdjustButton(props: CartLineQuantityAdjustButtonProps) {
43
+ const {
44
+ quantity,
45
+ min = 1,
46
+ max = 99,
47
+ onUpdate,
48
+ onRemove,
49
+ debounceMs = 300,
50
+ className,
51
+ decreaseProps,
52
+ increaseProps,
53
+ } = props;
54
+
55
+ // optimistic 显示的 quantity
56
+ const [optimistic, setOptimistic] = React.useState(quantity);
57
+ React.useEffect(() => setOptimistic(quantity), [quantity]);
58
+
59
+ const [pending, setPending] = React.useState(false);
60
+ const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
61
+
62
+ const flushUpdate = React.useCallback(
63
+ (target: number) => {
64
+ if (timerRef.current) clearTimeout(timerRef.current);
65
+ timerRef.current = setTimeout(async () => {
66
+ setPending(true);
67
+ try {
68
+ if (target < min) {
69
+ await onRemove?.();
70
+ } else {
71
+ await onUpdate?.(target);
72
+ }
73
+ } finally {
74
+ setPending(false);
75
+ }
76
+ }, debounceMs);
77
+ },
78
+ [min, onRemove, onUpdate, debounceMs],
79
+ );
80
+
81
+ const handleAdjust = (delta: number) => {
82
+ const next = optimistic + delta;
83
+ if (next > max) return;
84
+ setOptimistic(next);
85
+ flushUpdate(next);
86
+ };
87
+
88
+ React.useEffect(() => () => {
89
+ if (timerRef.current) clearTimeout(timerRef.current);
90
+ }, []);
91
+
92
+ return (
93
+ <div className={className} data-quantity-adjust style={{ display: 'inline-flex', alignItems: 'center' }}>
94
+ <button
95
+ type="button"
96
+ {...decreaseProps}
97
+ aria-label="减少"
98
+ data-quantity-decrease
99
+ onClick={() => handleAdjust(-1)}
100
+ disabled={pending && optimistic <= min}
101
+ >
102
+ {decreaseProps?.children ?? '−'}
103
+ </button>
104
+ <span data-quantity-value aria-live="polite" style={{ margin: '0 0.5em', minWidth: '1.5em', textAlign: 'center' }}>
105
+ {optimistic}
106
+ </span>
107
+ <button
108
+ type="button"
109
+ {...increaseProps}
110
+ aria-label="增加"
111
+ data-quantity-increase
112
+ onClick={() => handleAdjust(1)}
113
+ disabled={pending && optimistic >= max}
114
+ >
115
+ {increaseProps?.children ?? '+'}
116
+ </button>
117
+ </div>
118
+ );
119
+ }
@@ -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
+ }