@shopbb/helium 0.5.10 → 0.6.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 (132) hide show
  1. package/dist/cache/withCache.d.ts +49 -0
  2. package/dist/cache/withCache.d.ts.map +1 -0
  3. package/dist/cache/withCache.js +117 -0
  4. package/dist/cache/withCache.js.map +1 -0
  5. package/dist/components/AddToCartButton.d.ts +28 -22
  6. package/dist/components/AddToCartButton.d.ts.map +1 -1
  7. package/dist/components/AddToCartButton.js +36 -47
  8. package/dist/components/AddToCartButton.js.map +1 -1
  9. package/dist/components/BuyNowButton.d.ts +45 -0
  10. package/dist/components/BuyNowButton.d.ts.map +1 -0
  11. package/dist/components/BuyNowButton.js +49 -0
  12. package/dist/components/BuyNowButton.js.map +1 -0
  13. package/dist/components/CartCheckoutButton.d.ts +39 -0
  14. package/dist/components/CartCheckoutButton.d.ts.map +1 -0
  15. package/dist/components/CartCheckoutButton.js +32 -0
  16. package/dist/components/CartCheckoutButton.js.map +1 -0
  17. package/dist/components/CartCost.d.ts +43 -0
  18. package/dist/components/CartCost.d.ts.map +1 -0
  19. package/dist/components/CartCost.js +34 -0
  20. package/dist/components/CartCost.js.map +1 -0
  21. package/dist/components/CartForm.d.ts +201 -0
  22. package/dist/components/CartForm.d.ts.map +1 -0
  23. package/dist/components/CartForm.js +213 -0
  24. package/dist/components/CartForm.js.map +1 -0
  25. package/dist/components/CartLineProvider.d.ts +78 -0
  26. package/dist/components/CartLineProvider.d.ts.map +1 -0
  27. package/dist/components/CartLineProvider.js +46 -0
  28. package/dist/components/CartLineProvider.js.map +1 -0
  29. package/dist/components/CartLineQuantity.d.ts +24 -0
  30. package/dist/components/CartLineQuantity.d.ts.map +1 -0
  31. package/dist/components/CartLineQuantity.js +9 -0
  32. package/dist/components/CartLineQuantity.js.map +1 -0
  33. package/dist/components/DiscountSelector.d.ts.map +1 -1
  34. package/dist/components/DiscountSelector.js +8 -19
  35. package/dist/components/DiscountSelector.js.map +1 -1
  36. package/dist/components/Image.d.ts +18 -0
  37. package/dist/components/Image.d.ts.map +1 -1
  38. package/dist/components/Image.js +26 -0
  39. package/dist/components/Image.js.map +1 -1
  40. package/dist/components/Pagination.d.ts +82 -0
  41. package/dist/components/Pagination.d.ts.map +1 -0
  42. package/dist/components/Pagination.js +84 -0
  43. package/dist/components/Pagination.js.map +1 -0
  44. package/dist/components/RichText.d.ts +78 -0
  45. package/dist/components/RichText.d.ts.map +1 -0
  46. package/dist/components/RichText.js +93 -0
  47. package/dist/components/RichText.js.map +1 -0
  48. package/dist/components/Seo.d.ts +25 -0
  49. package/dist/components/Seo.d.ts.map +1 -0
  50. package/dist/components/Seo.js +54 -0
  51. package/dist/components/Seo.js.map +1 -0
  52. package/dist/components/hooks/useMoney.d.ts +40 -0
  53. package/dist/components/hooks/useMoney.d.ts.map +1 -0
  54. package/dist/components/hooks/useMoney.js +60 -0
  55. package/dist/components/hooks/useMoney.js.map +1 -0
  56. package/dist/components/hooks/useOptimisticCart.d.ts +50 -0
  57. package/dist/components/hooks/useOptimisticCart.d.ts.map +1 -0
  58. package/dist/components/hooks/useOptimisticCart.js +138 -0
  59. package/dist/components/hooks/useOptimisticCart.js.map +1 -0
  60. package/dist/components/index.d.ts +28 -0
  61. package/dist/components/index.d.ts.map +1 -1
  62. package/dist/components/index.js +21 -0
  63. package/dist/components/index.js.map +1 -1
  64. package/dist/createCartHandler.d.ts.map +1 -1
  65. package/dist/createCartHandler.js +57 -0
  66. package/dist/createCartHandler.js.map +1 -1
  67. package/dist/csp/csp.d.ts +57 -0
  68. package/dist/csp/csp.d.ts.map +1 -0
  69. package/dist/csp/csp.js +73 -0
  70. package/dist/csp/csp.js.map +1 -0
  71. package/dist/customer/createCustomerAccountClient.d.ts +43 -0
  72. package/dist/customer/createCustomerAccountClient.d.ts.map +1 -0
  73. package/dist/customer/createCustomerAccountClient.js +68 -0
  74. package/dist/customer/createCustomerAccountClient.js.map +1 -0
  75. package/dist/handleCartFormAction.d.ts +39 -0
  76. package/dist/handleCartFormAction.d.ts.map +1 -0
  77. package/dist/handleCartFormAction.js +103 -0
  78. package/dist/handleCartFormAction.js.map +1 -0
  79. package/dist/index.d.ts +18 -0
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +11 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/routing/storefrontRedirect.d.ts +37 -0
  84. package/dist/routing/storefrontRedirect.d.ts.map +1 -0
  85. package/dist/routing/storefrontRedirect.js +64 -0
  86. package/dist/routing/storefrontRedirect.js.map +1 -0
  87. package/dist/seo/getSeoMeta.d.ts +68 -0
  88. package/dist/seo/getSeoMeta.d.ts.map +1 -0
  89. package/dist/seo/getSeoMeta.js +89 -0
  90. package/dist/seo/getSeoMeta.js.map +1 -0
  91. package/dist/sitemap/sitemap.d.ts +55 -0
  92. package/dist/sitemap/sitemap.d.ts.map +1 -0
  93. package/dist/sitemap/sitemap.js +93 -0
  94. package/dist/sitemap/sitemap.js.map +1 -0
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/utils/flattenConnection.d.ts +25 -0
  98. package/dist/utils/flattenConnection.d.ts.map +1 -0
  99. package/dist/utils/flattenConnection.js +25 -0
  100. package/dist/utils/flattenConnection.js.map +1 -0
  101. package/dist/utils/parseGid.d.ts +17 -0
  102. package/dist/utils/parseGid.d.ts.map +1 -0
  103. package/dist/utils/parseGid.js +19 -0
  104. package/dist/utils/parseGid.js.map +1 -0
  105. package/package.json +1 -1
  106. package/src/cache/withCache.ts +144 -0
  107. package/src/components/AddToCartButton.tsx +94 -56
  108. package/src/components/BuyNowButton.tsx +135 -0
  109. package/src/components/CartCheckoutButton.tsx +97 -0
  110. package/src/components/CartCost.tsx +65 -0
  111. package/src/components/CartForm.tsx +311 -0
  112. package/src/components/CartLineProvider.tsx +77 -0
  113. package/src/components/CartLineQuantity.tsx +37 -0
  114. package/src/components/DiscountSelector.tsx +34 -45
  115. package/src/components/Image.tsx +27 -0
  116. package/src/components/Pagination.tsx +139 -0
  117. package/src/components/RichText.tsx +122 -0
  118. package/src/components/Seo.tsx +61 -0
  119. package/src/components/hooks/useMoney.ts +87 -0
  120. package/src/components/hooks/useOptimisticCart.ts +173 -0
  121. package/src/components/index.ts +44 -0
  122. package/src/createCartHandler.ts +71 -0
  123. package/src/csp/csp.tsx +119 -0
  124. package/src/customer/createCustomerAccountClient.ts +89 -0
  125. package/src/handleCartFormAction.ts +129 -0
  126. package/src/index.ts +24 -0
  127. package/src/routing/storefrontRedirect.ts +86 -0
  128. package/src/seo/getSeoMeta.ts +125 -0
  129. package/src/sitemap/sitemap.ts +121 -0
  130. package/src/types.ts +12 -1
  131. package/src/utils/flattenConnection.ts +33 -0
  132. package/src/utils/parseGid.ts +25 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * <CartForm> — 对齐 Shopify Hydrogen 的 CartForm 组件
3
+ *
4
+ * Hydrogen 文档:https://shopify.dev/docs/api/hydrogen/latest/components/cartform
5
+ *
6
+ * 用法:
7
+ *
8
+ * import { CartForm } from '@shopby/helium';
9
+ *
10
+ * // 加入购物车
11
+ * <CartForm
12
+ * action={CartForm.ACTIONS.LinesAdd}
13
+ * inputs={{ lines: [{ merchandiseId, quantity: 1 }] }}
14
+ * route="/cart"
15
+ * >
16
+ * <button type="submit">加入购物车</button>
17
+ * </CartForm>
18
+ *
19
+ * // 自定义按钮渲染
20
+ * <CartForm action={CartForm.ACTIONS.LinesAdd} inputs={{ lines }}>
21
+ * {(fetcher) => (
22
+ * <button disabled={fetcher.state !== 'idle'}>
23
+ * {fetcher.state === 'submitting' ? '添加中…' : '加入购物车'}
24
+ * </button>
25
+ * )}
26
+ * </CartForm>
27
+ *
28
+ * 服务端(route action):
29
+ *
30
+ * import { CartForm } from '@shopbb/helium';
31
+ *
32
+ * export async function action(request, context) {
33
+ * const formData = await request.formData();
34
+ * const { action, inputs } = CartForm.getFormInput(formData);
35
+ * // ... switch(action) { ... }
36
+ * }
37
+ *
38
+ * ─────────────────────────────────────────────────────────────────
39
+ *
40
+ * 实现说明:
41
+ *
42
+ * - 渲染原生 <form method="POST">,无 JS 也能工作(form 默认提交 = 整页 POST)
43
+ * - INPUT_NAME = "cartFormInput"(hidden input,value = JSON.stringify({action, inputs}))
44
+ * - JS 加载后,由 helium 提供的 useCartFormFetcher hook(或 useFetcher 实现) 拦截 submit 实现"不跳页"
45
+ * - 当前简化实现:hydrate 后 onSubmit preventDefault,自己用 fetch 调,调完后通过 CartProvider.applyCart 更新
46
+ */
47
+
48
+ import * as React from 'react';
49
+ import { useCartOptional } from './CartProvider';
50
+ import { __registerPendingMutation } from './hooks/useOptimisticCart';
51
+
52
+ // ============================================================
53
+ // Action 枚举 — 对齐 Hydrogen CartForm.ACTIONS
54
+ // ============================================================
55
+
56
+ export const CART_FORM_ACTIONS = {
57
+ /** Update cart attributes */
58
+ AttributesUpdateInput: 'AttributesUpdateInput' as const,
59
+ /** Update buyer identity (email, customerAccessToken, country, etc.) */
60
+ BuyerIdentityUpdate: 'BuyerIdentityUpdate' as const,
61
+ /** Create a new cart */
62
+ Create: 'Create' as const,
63
+ /** Replace discount codes */
64
+ DiscountCodesUpdate: 'DiscountCodesUpdate' as const,
65
+ /** Replace gift card codes */
66
+ GiftCardCodesUpdate: 'GiftCardCodesUpdate' as const,
67
+ /** Append gift card codes */
68
+ GiftCardCodesAdd: 'GiftCardCodesAdd' as const,
69
+ /** Remove specific gift card codes */
70
+ GiftCardCodesRemove: 'GiftCardCodesRemove' as const,
71
+ /** Add cart lines */
72
+ LinesAdd: 'LinesAdd' as const,
73
+ /** Update cart lines (quantity / attributes) */
74
+ LinesUpdate: 'LinesUpdate' as const,
75
+ /** Remove cart lines */
76
+ LinesRemove: 'LinesRemove' as const,
77
+ /** Update cart note */
78
+ NoteUpdate: 'NoteUpdate' as const,
79
+ /** Update selected delivery options */
80
+ SelectedDeliveryOptionsUpdate: 'SelectedDeliveryOptionsUpdate' as const,
81
+ /** Set metafields */
82
+ MetafieldsSet: 'MetafieldsSet' as const,
83
+ /** Delete metafields */
84
+ MetafieldsDelete: 'MetafieldsDelete' as const,
85
+ } as const;
86
+
87
+ export type CartFormAction = (typeof CART_FORM_ACTIONS)[keyof typeof CART_FORM_ACTIONS] | `Custom${string}`;
88
+
89
+ // ============================================================
90
+ // Action input types — 与 Hydrogen 一致
91
+ // ============================================================
92
+
93
+ export type CartLineInput = { merchandiseId: string; quantity?: number; attributes?: Array<{ key: string; value: string }> };
94
+ export type CartLineUpdateInput = { id: string; quantity?: number; merchandiseId?: string; attributes?: Array<{ key: string; value: string }> };
95
+
96
+ interface OtherFormData {
97
+ [key: string]: unknown;
98
+ }
99
+
100
+ export type CartFormInput =
101
+ | { action: 'LinesAdd'; inputs: { lines: CartLineInput[] } & OtherFormData }
102
+ | { action: 'LinesUpdate'; inputs: { lines: CartLineUpdateInput[] } & OtherFormData }
103
+ | { action: 'LinesRemove'; inputs: { lineIds: string[] } & OtherFormData }
104
+ | { action: 'DiscountCodesUpdate'; inputs: { discountCodes: string[] } & OtherFormData }
105
+ | { action: 'NoteUpdate'; inputs: { note: string } & OtherFormData }
106
+ | { action: 'BuyerIdentityUpdate'; inputs: { buyerIdentity: Record<string, unknown> } & OtherFormData }
107
+ | { action: 'AttributesUpdateInput'; inputs: { attributes: Array<{ key: string; value: string }> } & OtherFormData }
108
+ | { action: 'Create'; inputs: { input: Record<string, unknown> } & OtherFormData }
109
+ | { action: `Custom${string}`; inputs: Record<string, unknown> };
110
+
111
+ // ============================================================
112
+ // <CartForm> 组件
113
+ // ============================================================
114
+
115
+ /** 服务端 / 客户端共享的 hidden field name — 对齐 Hydrogen */
116
+ const INPUT_NAME = 'cartFormInput';
117
+
118
+ /**
119
+ * Fetcher 状态对象 — 模仿 Remix useFetcher
120
+ *
121
+ * 关键属性:
122
+ * - state: 'idle' | 'submitting' | 'loading'
123
+ * - data: 服务端 action 返回的 JSON(成功时)
124
+ * - error: 错误信息
125
+ */
126
+ export interface CartFormFetcher {
127
+ state: 'idle' | 'submitting' | 'loading';
128
+ data: any;
129
+ error: string | null;
130
+ /** 取最近一次提交的 form data(optimistic UI 用) */
131
+ formData: FormData | null;
132
+ }
133
+
134
+ /**
135
+ * CartFormFetcher Context — 让 CartForm 内部任何位置都能用 useFetcher() 拿状态
136
+ *
137
+ * Hydrogen 是用 Remix global fetcher,我们用一个本地 Context。children 既可以是
138
+ * render prop(推荐),也可以是普通节点 + useFetcher() 调用。
139
+ */
140
+ const FetcherContext = React.createContext<CartFormFetcher | null>(null);
141
+
142
+ /**
143
+ * 在 <CartForm> 内部任何深度的组件里拿 fetcher 状态。
144
+ *
145
+ * <CartForm action="LinesAdd" inputs={{lines}}>
146
+ * <MyButton />
147
+ * </CartForm>
148
+ *
149
+ * function MyButton() {
150
+ * const fetcher = useFetcher();
151
+ * return <button disabled={fetcher.state !== 'idle'}>添加</button>;
152
+ * }
153
+ */
154
+ export function useFetcher(): CartFormFetcher {
155
+ const ctx = React.useContext(FetcherContext);
156
+ if (!ctx) {
157
+ throw new Error('useFetcher must be used inside a <CartForm>');
158
+ }
159
+ return ctx;
160
+ }
161
+
162
+ export interface CartFormCommonProps {
163
+ /** children 可以是普通节点,也可以是 (fetcher) => ReactNode */
164
+ children: React.ReactNode | ((fetcher: CartFormFetcher) => React.ReactNode);
165
+ /** 提交的 route。默认 "/cart" */
166
+ route?: string;
167
+ /** Optional key — 同一 key 的 fetcher state 共享。当前简化版未使用 */
168
+ fetcherKey?: string;
169
+ /** 表单 className(不影响行为) */
170
+ className?: string;
171
+ }
172
+
173
+ // Discriminated union — action + inputs 强制对齐
174
+ export type CartFormProps = CartFormInput & CartFormCommonProps;
175
+
176
+ interface CartFormImpl {
177
+ (props: CartFormProps): React.ReactElement;
178
+ ACTIONS: typeof CART_FORM_ACTIONS;
179
+ INPUT_NAME: typeof INPUT_NAME;
180
+ /**
181
+ * 服务端 helper:从 request.formData() 解析出 { action, inputs }
182
+ *
183
+ * const formData = await request.formData();
184
+ * const { action, inputs } = CartForm.getFormInput(formData);
185
+ */
186
+ getFormInput: (formData: FormData) => CartFormInput;
187
+ }
188
+
189
+ const CartFormBase = (props: CartFormProps): React.ReactElement => {
190
+ const { children, route = '/cart', className, action, inputs, ...rest } = props as any;
191
+ const cartCtx = useCartOptional();
192
+
193
+ // 序列化 { action, inputs } 一并放进 hidden input
194
+ // 服务端用 CartForm.getFormInput(formData) 反序列化
195
+ const payload = React.useMemo(() => JSON.stringify({ action, inputs }), [action, inputs]);
196
+
197
+ const [fetcher, setFetcher] = React.useState<CartFormFetcher>({
198
+ state: 'idle',
199
+ data: null,
200
+ error: null,
201
+ formData: null,
202
+ });
203
+
204
+ // hydrate 后拦截 submit;SSR 时 onSubmit 不存在 → form 原生 POST
205
+ const handleSubmit = React.useCallback(
206
+ async (e: React.FormEvent<HTMLFormElement>) => {
207
+ e.preventDefault();
208
+ const form = e.currentTarget;
209
+ const formData = new FormData(form);
210
+ setFetcher({ state: 'submitting', data: null, error: null, formData });
211
+
212
+ // 注册到 pending store — useOptimisticCart 会看到这个 mutation 立刻应用
213
+ // 只对 cart line 类型的 action 做(DiscountCodesUpdate / Custom* 等不做 optimistic)
214
+ let unregisterPending: (() => void) | null = null;
215
+ if (action === 'LinesAdd' || action === 'LinesUpdate' || action === 'LinesRemove') {
216
+ const pendingId = `${action}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
217
+ unregisterPending = __registerPendingMutation({ id: pendingId, action: action as any, inputs });
218
+ }
219
+
220
+ try {
221
+ // 显式标记 X-Helium-Fetch — 让服务端知道这是 JS fetch 不是原生 form POST
222
+ // 服务端 handleCartFormAction 看到此 header 会返回 JSON 而不是 303 redirect
223
+ const res = await fetch(route, {
224
+ method: 'POST',
225
+ body: formData,
226
+ credentials: 'include',
227
+ headers: { 'Accept': 'application/json', 'X-Helium-Fetch': '1' },
228
+ });
229
+ const data: any = await res.json().catch(() => null);
230
+ if (!res.ok || data?.error || data?.userErrors?.length) {
231
+ throw new Error(data?.error || data?.userErrors?.[0]?.message || `HTTP ${res.status}`);
232
+ }
233
+ // 服务端约定:成功时返回 { cart: Cart }
234
+ // 这里直接 applyCart 到 CartProvider,避免 refetch
235
+ if (data?.cart && cartCtx?.applyCart) {
236
+ cartCtx.applyCart(data.cart);
237
+ }
238
+ setFetcher({ state: 'idle', data, error: null, formData: null });
239
+ } catch (err: any) {
240
+ setFetcher({ state: 'idle', data: null, error: err.message || String(err), formData: null });
241
+ } finally {
242
+ unregisterPending?.();
243
+ }
244
+ },
245
+ [route, cartCtx, action, inputs],
246
+ );
247
+
248
+ const renderedChildren = typeof children === 'function' ? (children as (f: CartFormFetcher) => React.ReactNode)(fetcher) : children;
249
+
250
+ return (
251
+ <FetcherContext.Provider value={fetcher}>
252
+ <form
253
+ method="POST"
254
+ action={route}
255
+ onSubmit={handleSubmit}
256
+ className={className}
257
+ data-cart-form
258
+ data-cart-action={action}
259
+ >
260
+ <input type="hidden" name={INPUT_NAME} value={payload} />
261
+ {renderedChildren}
262
+ </form>
263
+ </FetcherContext.Provider>
264
+ );
265
+ };
266
+
267
+ /**
268
+ * 服务端 helper:从 request.formData() 还原出 { action, inputs }
269
+ *
270
+ * 与 Hydrogen 完全一致的 API:
271
+ *
272
+ * const formData = await request.formData();
273
+ * const { action, inputs } = CartForm.getFormInput(formData);
274
+ */
275
+ function getFormInput(formData: FormData): CartFormInput {
276
+ const raw = formData.get(INPUT_NAME);
277
+ if (typeof raw !== 'string') {
278
+ throw new Error(`CartForm.getFormInput: missing field "${INPUT_NAME}" in form data`);
279
+ }
280
+ let parsed: any;
281
+ try {
282
+ parsed = JSON.parse(raw);
283
+ } catch {
284
+ throw new Error(`CartForm.getFormInput: field "${INPUT_NAME}" is not valid JSON`);
285
+ }
286
+ if (!parsed || typeof parsed.action !== 'string') {
287
+ throw new Error('CartForm.getFormInput: missing "action" in parsed payload');
288
+ }
289
+
290
+ // 合并 form 里的其它字段进 inputs(对齐 Hydrogen 的 OtherFormData 设计)
291
+ // <CartForm action=NoteUpdate><input name="note" /></CartForm> 这种场景
292
+ // FormData 里同时有 cartFormInput 和 note 两个字段,inputs.note 来自 FormData 而不是 JSON。
293
+ const otherInputs: Record<string, unknown> = {};
294
+ for (const [key, value] of formData.entries()) {
295
+ if (key === INPUT_NAME) continue;
296
+ otherInputs[key] = value;
297
+ }
298
+
299
+ return {
300
+ action: parsed.action,
301
+ inputs: { ...otherInputs, ...(parsed.inputs || {}) },
302
+ } as CartFormInput;
303
+ }
304
+
305
+ // 把 ACTIONS / INPUT_NAME / getFormInput 挂到 CartForm 对象上 — 对齐 Hydrogen
306
+ const CartForm = CartFormBase as CartFormImpl;
307
+ CartForm.ACTIONS = CART_FORM_ACTIONS;
308
+ CartForm.INPUT_NAME = INPUT_NAME;
309
+ CartForm.getFormInput = getFormInput;
310
+
311
+ export { CartForm };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * <CartLineProvider> + useCartLine() — 对齐 Hydrogen React
3
+ *
4
+ * 在 cart 渲染时,把单行 cart line 通过 Context 提供给子组件。子组件如
5
+ * <CartLineQuantity>、<CartLineQuantityAdjustButton>、商品图缩略图等都能直接
6
+ * useCartLine() 拿到 line 数据,无需 prop drilling。
7
+ *
8
+ * 用法:
9
+ *
10
+ * {cart.lines.nodes.map((line) => (
11
+ * <CartLineProvider key={line.id} line={line}>
12
+ * <CartLineImage />
13
+ * <CartLineTitle />
14
+ * <CartLineQuantity />
15
+ * <CartLineQuantityAdjustButton />
16
+ * </CartLineProvider>
17
+ * ))}
18
+ *
19
+ * function CartLineImage() {
20
+ * const line = useCartLine();
21
+ * return <Image data={line.merchandise.image} />;
22
+ * }
23
+ */
24
+
25
+ import * as React from 'react';
26
+
27
+ /**
28
+ * Cart line 的核心字段。任何符合此 shape 的对象都能传入。
29
+ * 实际可以包含更多字段(merchandise / attributes / 等),由 oxygen Storefront
30
+ * GraphQL schema 决定。
31
+ */
32
+ export interface CartLineLike {
33
+ id: string;
34
+ quantity: number;
35
+ merchandise?: {
36
+ id?: string;
37
+ title?: string;
38
+ image?: { url: string; altText?: string | null } | null;
39
+ price?: { amount: string; currencyCode: string };
40
+ product?: { title?: string; handle?: string } | null;
41
+ };
42
+ cost?: {
43
+ totalAmount: { amount: string; currencyCode: string };
44
+ amountPerQuantity?: { amount: string; currencyCode: string };
45
+ };
46
+ attributes?: Array<{ key: string; value: string }>;
47
+ [key: string]: any;
48
+ }
49
+
50
+ const CartLineContext = React.createContext<CartLineLike | null>(null);
51
+
52
+ export interface CartLineProviderProps {
53
+ line: CartLineLike;
54
+ children: React.ReactNode;
55
+ }
56
+
57
+ export function CartLineProvider({ line, children }: CartLineProviderProps) {
58
+ return <CartLineContext.Provider value={line}>{children}</CartLineContext.Provider>;
59
+ }
60
+
61
+ /**
62
+ * 拿当前 cart line。Provider 外抛错。
63
+ */
64
+ export function useCartLine(): CartLineLike {
65
+ const line = React.useContext(CartLineContext);
66
+ if (!line) {
67
+ throw new Error('useCartLine must be used inside <CartLineProvider>');
68
+ }
69
+ return line;
70
+ }
71
+
72
+ /**
73
+ * 非 throw 版本:Provider 外返回 null。供能在 Provider 内外都工作的组件用。
74
+ */
75
+ export function useCartLineOptional(): CartLineLike | null {
76
+ return React.useContext(CartLineContext);
77
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * <CartLineQuantity> — 对齐 Hydrogen React
3
+ *
4
+ * 渲染当前 cart line 的数量。从 <CartLineProvider> Context 拿 line.quantity。
5
+ *
6
+ * 用法:
7
+ * <CartLineProvider line={line}>
8
+ * 数量:<CartLineQuantity />
9
+ * </CartLineProvider>
10
+ *
11
+ * 自定义渲染元素:
12
+ * <CartLineQuantity as="strong" className="qty-badge" />
13
+ */
14
+
15
+ import * as React from 'react';
16
+ import { useCartLine } from './CartLineProvider';
17
+
18
+ export interface CartLineQuantityProps {
19
+ /** 渲染标签,默认 span */
20
+ as?: keyof JSX.IntrinsicElements;
21
+ /** className */
22
+ className?: string;
23
+ /** 子节点 — 数字会显示在 children 之前;通常不传 */
24
+ children?: React.ReactNode;
25
+ }
26
+
27
+ export function CartLineQuantity(props: CartLineQuantityProps) {
28
+ const { as, className, children } = props;
29
+ const line = useCartLine();
30
+ const Tag: any = as || 'span';
31
+ return (
32
+ <Tag className={className} data-cart-line-quantity>
33
+ {line.quantity}
34
+ {children}
35
+ </Tag>
36
+ );
37
+ }
@@ -18,9 +18,9 @@
18
18
 
19
19
  import * as React from 'react';
20
20
  import { useDiscounts, type DiscountClaim } from './DiscountProvider';
21
- import { useAnalytics } from './AnalyticsProvider';
22
21
  import { Money } from './Money';
23
22
  import { useMounted } from './hooks/useMounted';
23
+ import { CartForm } from './CartForm';
24
24
 
25
25
  export interface DiscountSelectorProps {
26
26
  className?: string;
@@ -36,15 +36,11 @@ export function DiscountSelector(props: DiscountSelectorProps) {
36
36
  const {
37
37
  myDiscounts, myDiscountsStatus,
38
38
  cartAllocations, appliedClaim,
39
- selectCartDiscount,
40
39
  } = useDiscounts();
41
- const analytics = useAnalytics();
42
40
 
43
41
  const [open, setOpen] = React.useState(false);
44
- const [pending, setPending] = React.useState(false);
45
42
 
46
43
  // SSR + client 首次 render:返回固定占位,保证 hydration 不 mismatch。
47
- // mounted 后才进入真正的渲染分支。
48
44
  if (!mounted) {
49
45
  return <div data-discount-selector data-ssr-placeholder className={className} />;
50
46
  }
@@ -54,17 +50,6 @@ export function DiscountSelector(props: DiscountSelectorProps) {
54
50
  const appliedClaimId = appliedClaim?.claimId ?? null;
55
51
  const appliedTitle = appliedClaim?.title ?? appliedAlloc?.title;
56
52
  const appliedCode = appliedClaim?.code ?? appliedAlloc?.code;
57
-
58
- const handleSelect = async (claimId: string) => {
59
- setPending(true);
60
- try {
61
- await selectCartDiscount(claimId);
62
- analytics.emit('discount_select', { claimId });
63
- } finally {
64
- setPending(false);
65
- setOpen(false);
66
- }
67
- };
68
53
  // 未登录
69
54
  if (myDiscountsStatus === 'unauthenticated') {
70
55
  return unauthenticatedFallback !== undefined ? <>{unauthenticatedFallback}</> : (
@@ -108,7 +93,7 @@ export function DiscountSelector(props: DiscountSelectorProps) {
108
93
  )}
109
94
  </div>
110
95
  <div data-actions>
111
- <button type="button" onClick={() => setOpen(true)} disabled={pending}>切换</button>
96
+ <button type="button" onClick={() => setOpen(true)}>切换</button>
112
97
  </div>
113
98
  </div>
114
99
  )
@@ -117,7 +102,7 @@ export function DiscountSelector(props: DiscountSelectorProps) {
117
102
  <div data-info>
118
103
  <span data-label>有 {availableClaims.length} 张可用优惠券</span>
119
104
  </div>
120
- <button type="button" onClick={() => setOpen(true)} disabled={pending}>选择</button>
105
+ <button type="button" onClick={() => setOpen(true)}>选择</button>
121
106
  </div>
122
107
  )}
123
108
  </div>
@@ -126,9 +111,7 @@ export function DiscountSelector(props: DiscountSelectorProps) {
126
111
  <DiscountPickerSheet
127
112
  claims={availableClaims}
128
113
  currentClaimId={appliedClaimId}
129
- onSelect={handleSelect}
130
114
  onClose={() => setOpen(false)}
131
- pending={pending}
132
115
  />
133
116
  )}
134
117
  </>
@@ -140,13 +123,11 @@ export function DiscountSelector(props: DiscountSelectorProps) {
140
123
  * Demo 实现为屏底固定面板;商家可以替换为 modal / 抽屉等。
141
124
  */
142
125
  function DiscountPickerSheet({
143
- claims, currentClaimId, onSelect, onClose, pending,
126
+ claims, currentClaimId, onClose,
144
127
  }: {
145
128
  claims: DiscountClaim[];
146
129
  currentClaimId: string | null;
147
- onSelect: (claimId: string) => void;
148
130
  onClose: () => void;
149
- pending: boolean;
150
131
  }) {
151
132
  return (
152
133
  <div data-discount-picker-overlay onClick={onClose}>
@@ -157,34 +138,42 @@ function DiscountPickerSheet({
157
138
  </div>
158
139
  <div data-sheet-list>
159
140
  {claims.map((c) => {
141
+ // claim_id 既可能是 gid 也可能是 raw —— oxygen mutation 接受两种
160
142
  const id = c.id.replace(/^gid:\/\/shopbb\/DiscountClaim\//, '');
161
143
  const isCurrent = currentClaimId === id;
162
144
  return (
163
- <button
145
+ // 每张券一个 <CartForm>,submit 走 /cart action → cart.selectDiscount
146
+ <CartForm
164
147
  key={c.id}
165
- type="button"
166
- data-claim-card
167
- data-current={isCurrent ? '' : undefined}
168
- onClick={() => onSelect(id)}
169
- disabled={pending}
148
+ action="CustomDiscountSelect"
149
+ inputs={{ claimId: id }}
170
150
  >
171
- <div data-claim-left>
172
- <div data-claim-value>{formatValue(c)}</div>
173
- {c.discount.minSubtotal && (
174
- <div data-claim-min>
175
- 满 <Money data={{ amount: c.discount.minSubtotal.amount, currencyCode: c.discount.minSubtotal.currencyCode }} /> 可用
151
+ {(fetcher) => (
152
+ <button
153
+ type="submit"
154
+ data-claim-card
155
+ data-current={isCurrent ? '' : undefined}
156
+ disabled={fetcher.state !== 'idle'}
157
+ >
158
+ <div data-claim-left>
159
+ <div data-claim-value>{formatValue(c)}</div>
160
+ {c.discount.minSubtotal && (
161
+ <div data-claim-min>
162
+ 满 <Money data={{ amount: c.discount.minSubtotal.amount, currencyCode: c.discount.minSubtotal.currencyCode }} /> 可用
163
+ </div>
164
+ )}
165
+ </div>
166
+ <div data-claim-right>
167
+ <div data-claim-title>{c.discount.title}</div>
168
+ {c.discount.code && <div data-claim-code>{c.discount.code}</div>}
169
+ {c.expiresAt && (
170
+ <div data-claim-expiry>{new Date(c.expiresAt).toLocaleDateString('zh-CN')} 过期</div>
171
+ )}
176
172
  </div>
177
- )}
178
- </div>
179
- <div data-claim-right>
180
- <div data-claim-title>{c.discount.title}</div>
181
- {c.discount.code && <div data-claim-code>{c.discount.code}</div>}
182
- {c.expiresAt && (
183
- <div data-claim-expiry>{new Date(c.expiresAt).toLocaleDateString('zh-CN')} 过期</div>
184
- )}
185
- </div>
186
- {isCurrent && <span data-claim-current-tag>当前使用</span>}
187
- </button>
173
+ {isCurrent && <span data-claim-current-tag>当前使用</span>}
174
+ </button>
175
+ )}
176
+ </CartForm>
188
177
  );
189
178
  })}
190
179
  </div>
@@ -1,3 +1,30 @@
1
+ /**
2
+ * IMAGE_FRAGMENT — 对齐 Hydrogen React 同名常量
3
+ *
4
+ * 商家在 GraphQL 查询里直接用:
5
+ *
6
+ * import { IMAGE_FRAGMENT } from '@shopbb/helium';
7
+ *
8
+ * const Q = `
9
+ * ${IMAGE_FRAGMENT}
10
+ * query {
11
+ * product(handle: "x") {
12
+ * featuredImage { ...Image }
13
+ * images(first: 5) { nodes { ...Image } }
14
+ * }
15
+ * }
16
+ * `;
17
+ */
18
+ export const IMAGE_FRAGMENT = /* GraphQL */ `
19
+ fragment Image on Image {
20
+ id
21
+ url
22
+ altText
23
+ width
24
+ height
25
+ }
26
+ `;
27
+
1
28
  /**
2
29
  * <Image> — 响应式商品图片
3
30
  *