@shopbb/helium 0.4.0 → 0.5.1

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.
@@ -87,19 +87,24 @@ export interface DiscountContextValue {
87
87
  /** 我领过的有效券 */
88
88
  myDiscounts: DiscountClaim[];
89
89
  myDiscountsStatus: 'unauthenticated' | 'idle' | 'loading' | 'error';
90
- /** 当前 cart 上应用的码 */
91
- appliedToCart: CartDiscountCode[];
92
- /** 当前 cart 上的折扣分配明细 */
90
+ /**
91
+ * 当前 cart 上的折扣分配明细。
92
+ * W5 起:cart 上最多一张券(自动选最佳 / 买家手动选)。
93
+ */
93
94
  cartAllocations: CartDiscountAllocation[];
94
- /** 当前 cart 上最大可省(计算了用户已领但未应用的券) */
95
- bestApplicableForCart: { discount: DiscountClaim; estimatedAmount: number } | null;
95
+ /** 当前应用的 claim ID(W5),null = 无折扣或买家未登录 */
96
+ appliedClaimId: string | null;
97
+ /** 当前应用的券标题 + code(来自 Cart.appliedDiscountClaim) */
98
+ appliedClaim: { claimId: string; code: string | null; title: string } | null;
96
99
  /** 错误 */
97
100
  error: string | null;
98
101
 
99
102
  // 操作
100
103
  claim: (code: string) => Promise<{ claim?: DiscountClaim; userErrors: DiscountUserError[] }>;
101
- applyToCart: (code: string) => Promise<{ userErrors: DiscountUserError[] }>;
102
- removeFromCart: (code: string) => Promise<{ userErrors: DiscountUserError[] }>;
104
+ /** 显式选中一张已领的券应用到当前 cart */
105
+ selectCartDiscount: (claimId: string) => Promise<{ userErrors: DiscountUserError[] }>;
106
+ /** 清掉显式选择,让 cart 回到"自动选最佳"或"无折扣" */
107
+ clearCartDiscount: () => Promise<{ userErrors: DiscountUserError[] }>;
103
108
  refetchMyDiscounts: () => Promise<void>;
104
109
  refetchPublicDiscounts: () => Promise<void>;
105
110
  }
@@ -149,19 +154,25 @@ export function DiscountProvider(props: DiscountProviderProps) {
149
154
  }, [tokenProvider]);
150
155
 
151
156
  const storefrontGql = React.useCallback(
152
- async <T = any>(query: string, variables?: any): Promise<{ data?: T; errors?: any[] }> => {
157
+ async <T = any>(query: string, variables?: any, opts?: { withBuyerAuth?: boolean }): Promise<{ data?: T; errors?: any[] }> => {
158
+ const headers: Record<string, string> = {
159
+ 'Content-Type': 'application/json',
160
+ 'X-Storefront-Access-Token': shop.storefrontAccessToken,
161
+ };
162
+ // 部分 storefront mutation 需要登录买家(如 cartDiscountSelect)
163
+ if (opts?.withBuyerAuth) {
164
+ const token = getToken();
165
+ if (token) headers.Authorization = `Bearer ${token}`;
166
+ }
153
167
  const res = await fetch(shop.apiUrl, {
154
168
  method: 'POST',
155
- headers: {
156
- 'Content-Type': 'application/json',
157
- 'X-Storefront-Access-Token': shop.storefrontAccessToken,
158
- },
169
+ headers,
159
170
  body: JSON.stringify({ query, variables }),
160
171
  credentials: 'include',
161
172
  });
162
173
  return res.json();
163
174
  },
164
- [shop.apiUrl, shop.storefrontAccessToken],
175
+ [shop.apiUrl, shop.storefrontAccessToken, getToken],
165
176
  );
166
177
 
167
178
  const customerGql = React.useCallback(
@@ -280,95 +291,90 @@ export function DiscountProvider(props: DiscountProviderProps) {
280
291
  [customerGql, refetchMyDiscounts],
281
292
  );
282
293
 
283
- // ----- cart apply / remove -----
284
- const appliedToCart = cartCtx?.cart && (cartCtx.cart as any).discountCodes ? (cartCtx.cart as any).discountCodes as CartDiscountCode[] : [];
285
- const cartAllocations = cartCtx?.cart && (cartCtx.cart as any).discountAllocations ? (cartCtx.cart as any).discountAllocations as CartDiscountAllocation[] : [];
286
-
287
- const applyCodes = React.useCallback(
288
- async (codes: string[]) => {
289
- if (!cartCtx?.cart) return { userErrors: [{ code: 'NO_CART', message: 'cart 不存在' }] };
290
- const result = await storefrontGql<{ cartDiscountCodesUpdate: { cart: any; userErrors: DiscountUserError[] } }>(
291
- `mutation U($id: ID!, $codes: [String!]!) {
292
- cartDiscountCodesUpdate(cartId: $id, discountCodes: $codes) {
293
- cart { id }
294
- userErrors { field code message }
295
- }
296
- }`,
297
- { id: cartCtx.cart.id, codes },
298
- );
299
- if (result.errors?.length) {
300
- return { userErrors: [{ code: 'NETWORK_ERROR', message: result.errors[0].message }] };
294
+ // ----- 当前 cart 上的折扣(服务端算好的) -----
295
+ const cartAllocations = cartCtx?.cart && (cartCtx.cart as any).discountAllocations
296
+ ? (cartCtx.cart as any).discountAllocations as CartDiscountAllocation[]
297
+ : [];
298
+ const appliedClaim: { claimId: string; code: string | null; title: string } | null =
299
+ cartCtx?.cart && (cartCtx.cart as any).appliedDiscountClaim
300
+ ? (cartCtx.cart as any).appliedDiscountClaim
301
+ : null;
302
+ const appliedClaimId = appliedClaim?.claimId ?? null;
303
+
304
+ // ----- W5: select / clear cart discount -----
305
+ // Storefront GraphQL(不是 Customer Account),但需要 buyer JWT 让服务端 ctx.buyerUserId
306
+ // 有值。withBuyerAuth=true 触发 storefrontGql 加 Authorization header。
307
+ const selectCartDiscount = React.useCallback(
308
+ async (claimId: string) => {
309
+ if (!cartCtx?.cart) {
310
+ return { userErrors: [{ code: 'NO_CART', message: 'cart 不存在' }] };
311
+ }
312
+ try {
313
+ const result = await storefrontGql<{ cartDiscountSelect: { cart: any; userErrors: DiscountUserError[] } }>(
314
+ `mutation S($id: ID!, $c: ID!) {
315
+ cartDiscountSelect(cartId: $id, claimId: $c) {
316
+ cart { id }
317
+ userErrors { field code message }
318
+ }
319
+ }`,
320
+ { id: cartCtx.cart.id, c: claimId },
321
+ { withBuyerAuth: true },
322
+ );
323
+ if (result.errors?.length) {
324
+ return { userErrors: [{ code: 'NETWORK_ERROR', message: result.errors[0].message }] };
325
+ }
326
+ await cartCtx.refetch();
327
+ return { userErrors: result.data!.cartDiscountSelect.userErrors };
328
+ } catch (e: any) {
329
+ return { userErrors: [{ code: 'NETWORK_ERROR', message: e?.message ?? String(e) }] };
301
330
  }
302
- // 刷 cart(拉新的 discountCodes / allocations)
303
- await cartCtx.refetch();
304
- return { userErrors: result.data!.cartDiscountCodesUpdate.userErrors };
305
331
  },
306
332
  [cartCtx, storefrontGql],
307
333
  );
308
334
 
309
- const applyToCart = React.useCallback(
310
- async (code: string) => {
311
- const existing = appliedToCart.map((d) => d.code);
312
- if (existing.includes(code.toUpperCase())) return { userErrors: [] };
313
- return applyCodes([...existing, code.toUpperCase()]);
314
- },
315
- [appliedToCart, applyCodes],
316
- );
317
-
318
- const removeFromCart = React.useCallback(
319
- async (code: string) => {
320
- const next = appliedToCart.map((d) => d.code).filter((c) => c !== code.toUpperCase());
321
- return applyCodes(next);
322
- },
323
- [appliedToCart, applyCodes],
324
- );
325
-
326
- // ----- best applicable for cart -----
327
- const bestApplicableForCart = React.useMemo(() => {
328
- if (!cartCtx?.cart || myDiscounts.length === 0) return null;
329
- const subtotalStr = (cartCtx.cart as any).cost?.subtotalAmount?.amount;
330
- const subtotal = Number(subtotalStr || 0);
331
- if (subtotal <= 0) return null;
332
-
333
- const currentApplied = new Set(appliedToCart.map((d) => d.code));
334
-
335
- let best: { discount: DiscountClaim; estimatedAmount: number } | null = null;
336
- for (const claimed of myDiscounts) {
337
- const d = claimed.discount;
338
- if (!d.code || claimed.isExpired) continue;
339
- if (currentApplied.has(d.code)) continue;
340
- // 门槛
341
- if (d.minSubtotal && Number(d.minSubtotal.amount) > subtotal) continue;
342
- // 估算折扣
343
- let amount = 0;
344
- if (d.valueType === 'PERCENTAGE' && d.valuePercentage != null) {
345
- amount = Math.floor(subtotal * (d.valuePercentage / 100) * 100) / 100;
346
- } else if (d.valueType === 'FIXED_AMOUNT' && d.valueAmount) {
347
- amount = Math.min(Number(d.valueAmount.amount), subtotal);
335
+ const clearCartDiscount = React.useCallback(
336
+ async () => {
337
+ if (!cartCtx?.cart) {
338
+ return { userErrors: [{ code: 'NO_CART', message: 'cart 不存在' }] };
348
339
  }
349
- if (amount > 0 && (best == null || amount > best.estimatedAmount)) {
350
- best = { discount: claimed, estimatedAmount: amount };
340
+ try {
341
+ const result = await storefrontGql<{ cartDiscountClear: { cart: any; userErrors: DiscountUserError[] } }>(
342
+ `mutation C($id: ID!) {
343
+ cartDiscountClear(cartId: $id) {
344
+ cart { id }
345
+ userErrors { field code message }
346
+ }
347
+ }`,
348
+ { id: cartCtx.cart.id },
349
+ { withBuyerAuth: true },
350
+ );
351
+ if (result.errors?.length) {
352
+ return { userErrors: [{ code: 'NETWORK_ERROR', message: result.errors[0].message }] };
353
+ }
354
+ await cartCtx.refetch();
355
+ return { userErrors: result.data!.cartDiscountClear.userErrors };
356
+ } catch (e: any) {
357
+ return { userErrors: [{ code: 'NETWORK_ERROR', message: e?.message ?? String(e) }] };
351
358
  }
352
- }
353
- return best;
354
- }, [cartCtx, myDiscounts, appliedToCart]);
359
+ },
360
+ [cartCtx, storefrontGql],
361
+ );
355
362
 
356
363
  const value: DiscountContextValue = React.useMemo(
357
364
  () => ({
358
365
  publicDiscounts, publicDiscountsStatus,
359
366
  myDiscounts, myDiscountsStatus,
360
- appliedToCart, cartAllocations,
361
- bestApplicableForCart,
367
+ cartAllocations,
368
+ appliedClaimId, appliedClaim,
362
369
  error,
363
- claim, applyToCart, removeFromCart,
370
+ claim, selectCartDiscount, clearCartDiscount,
364
371
  refetchMyDiscounts, refetchPublicDiscounts,
365
372
  }),
366
373
  [
367
374
  publicDiscounts, publicDiscountsStatus,
368
375
  myDiscounts, myDiscountsStatus,
369
- appliedToCart, cartAllocations,
370
- bestApplicableForCart, error,
371
- claim, applyToCart, removeFromCart,
376
+ cartAllocations, appliedClaimId, appliedClaim, error,
377
+ claim, selectCartDiscount, clearCartDiscount,
372
378
  refetchMyDiscounts, refetchPublicDiscounts,
373
379
  ],
374
380
  );
@@ -0,0 +1,209 @@
1
+ /**
2
+ * <DiscountSelector>
3
+ *
4
+ * Cart 上的优惠券选择器 — 卡片式 UI,对齐拼多多 / 京东体验。
5
+ *
6
+ * 行为:
7
+ * - 默认显示当前应用的券(服务端自动选了最佳,或买家显式选的)
8
+ * - 点"切换"打开抽屉/弹窗,列出该买家所有可用券(从 useDiscounts().myDiscounts)
9
+ * - 选另一张 → 调 cartDiscountSelect mutation
10
+ * - 点"不使用优惠券" → cartDiscountClear
11
+ *
12
+ * 未登录买家:渲染"登录后享受优惠"提示。
13
+ *
14
+ * 设计目标:
15
+ * - 这是 helium "脚手架" 的卡片式实现。商家可以替换为抽屉式 / 弹窗式自定义 UI。
16
+ * - 商家也可以直接用 useDiscounts() 自己渲染(提供 hook-only 路径)。
17
+ */
18
+
19
+ import * as React from 'react';
20
+ import { useDiscounts, type DiscountClaim } from './DiscountProvider';
21
+ import { useAnalytics } from './AnalyticsProvider';
22
+ import { Money } from './Money';
23
+
24
+ export interface DiscountSelectorProps {
25
+ className?: string;
26
+ /** 未登录时显示的内容;不传则显示默认 */
27
+ unauthenticatedFallback?: React.ReactNode;
28
+ /** 自定义渲染 */
29
+ renderApplied?: (props: { code: string | null; title: string; amount: { amount: string; currencyCode: string }; onChange: () => void; onClear: () => void }) => React.ReactNode;
30
+ }
31
+
32
+ export function DiscountSelector(props: DiscountSelectorProps) {
33
+ const { className, unauthenticatedFallback, renderApplied } = props;
34
+ const {
35
+ myDiscounts, myDiscountsStatus,
36
+ cartAllocations, appliedClaim,
37
+ selectCartDiscount, clearCartDiscount,
38
+ } = useDiscounts();
39
+ const analytics = useAnalytics();
40
+
41
+ const [open, setOpen] = React.useState(false);
42
+ const [pending, setPending] = React.useState(false);
43
+
44
+ // 服务端已经决定了 cart 上是哪张券
45
+ const appliedAlloc = cartAllocations[0];
46
+ const appliedClaimId = appliedClaim?.claimId ?? null;
47
+ const appliedTitle = appliedClaim?.title ?? appliedAlloc?.title;
48
+ const appliedCode = appliedClaim?.code ?? appliedAlloc?.code;
49
+
50
+ const handleSelect = async (claimId: string) => {
51
+ setPending(true);
52
+ try {
53
+ await selectCartDiscount(claimId);
54
+ analytics.emit('discount_select', { claimId });
55
+ } finally {
56
+ setPending(false);
57
+ setOpen(false);
58
+ }
59
+ };
60
+
61
+ const handleClear = async () => {
62
+ setPending(true);
63
+ try {
64
+ await clearCartDiscount();
65
+ } finally {
66
+ setPending(false);
67
+ setOpen(false);
68
+ }
69
+ };
70
+
71
+ // 未登录
72
+ if (myDiscountsStatus === 'unauthenticated') {
73
+ return unauthenticatedFallback !== undefined ? <>{unauthenticatedFallback}</> : (
74
+ <div data-discount-selector data-unauth className={className}>
75
+ <span>登录后可使用优惠券</span>
76
+ <a href="/login?next=/cart">去登录</a>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ if (myDiscountsStatus === 'loading') {
82
+ return <div data-discount-selector data-loading className={className}>加载优惠券…</div>;
83
+ }
84
+
85
+ const availableClaims = myDiscounts.filter((c) => !c.isExpired && c.remainingUses > 0);
86
+
87
+ if (availableClaims.length === 0) {
88
+ return (
89
+ <div data-discount-selector data-no-discount className={className}>
90
+ <span>暂无可用优惠券</span>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ return (
96
+ <>
97
+ <div data-discount-selector className={className}>
98
+ {appliedClaimId ? (
99
+ renderApplied ? renderApplied({
100
+ code: appliedCode ?? null,
101
+ title: appliedTitle ?? '',
102
+ amount: appliedAlloc?.discountedAmount ?? { amount: '0', currencyCode: 'CNY' },
103
+ onChange: () => setOpen(true),
104
+ onClear: handleClear,
105
+ }) : (
106
+ <div data-applied>
107
+ <div data-info>
108
+ <span data-label>已使用</span>
109
+ <strong data-title>{appliedTitle}</strong>
110
+ {appliedAlloc && (
111
+ <span data-amount>− <Money data={{ amount: appliedAlloc.discountedAmount.amount, currencyCode: appliedAlloc.discountedAmount.currencyCode }} /></span>
112
+ )}
113
+ </div>
114
+ <div data-actions>
115
+ <button type="button" onClick={() => setOpen(true)} disabled={pending}>切换</button>
116
+ <button type="button" onClick={handleClear} disabled={pending} data-clear>不使用</button>
117
+ </div>
118
+ </div>
119
+ )
120
+ ) : (
121
+ <div data-empty-applied>
122
+ <div data-info>
123
+ <span data-label>有 {availableClaims.length} 张可用优惠券</span>
124
+ </div>
125
+ <button type="button" onClick={() => setOpen(true)} disabled={pending}>选择</button>
126
+ </div>
127
+ )}
128
+ </div>
129
+
130
+ {open && (
131
+ <DiscountPickerSheet
132
+ claims={availableClaims}
133
+ currentClaimId={appliedClaimId}
134
+ onSelect={handleSelect}
135
+ onClose={() => setOpen(false)}
136
+ pending={pending}
137
+ />
138
+ )}
139
+ </>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * 抽屉式选券面板。
145
+ * Demo 实现为屏底固定面板;商家可以替换为 modal / 抽屉等。
146
+ */
147
+ function DiscountPickerSheet({
148
+ claims, currentClaimId, onSelect, onClose, pending,
149
+ }: {
150
+ claims: DiscountClaim[];
151
+ currentClaimId: string | null;
152
+ onSelect: (claimId: string) => void;
153
+ onClose: () => void;
154
+ pending: boolean;
155
+ }) {
156
+ return (
157
+ <div data-discount-picker-overlay onClick={onClose}>
158
+ <div data-discount-picker-sheet onClick={(e) => e.stopPropagation()}>
159
+ <div data-sheet-header>
160
+ <h3>选择优惠券</h3>
161
+ <button type="button" onClick={onClose} data-sheet-close>×</button>
162
+ </div>
163
+ <div data-sheet-list>
164
+ {claims.map((c) => {
165
+ const id = c.id.replace(/^gid:\/\/shopbb\/DiscountClaim\//, '');
166
+ const isCurrent = currentClaimId === id;
167
+ return (
168
+ <button
169
+ key={c.id}
170
+ type="button"
171
+ data-claim-card
172
+ data-current={isCurrent ? '' : undefined}
173
+ onClick={() => onSelect(id)}
174
+ disabled={pending}
175
+ >
176
+ <div data-claim-left>
177
+ <div data-claim-value>{formatValue(c)}</div>
178
+ {c.discount.minSubtotal && (
179
+ <div data-claim-min>
180
+ 满 <Money data={{ amount: c.discount.minSubtotal.amount, currencyCode: c.discount.minSubtotal.currencyCode }} /> 可用
181
+ </div>
182
+ )}
183
+ </div>
184
+ <div data-claim-right>
185
+ <div data-claim-title>{c.discount.title}</div>
186
+ {c.discount.code && <div data-claim-code>{c.discount.code}</div>}
187
+ {c.expiresAt && (
188
+ <div data-claim-expiry>{new Date(c.expiresAt).toLocaleDateString('zh-CN')} 过期</div>
189
+ )}
190
+ </div>
191
+ {isCurrent && <span data-claim-current-tag>当前使用</span>}
192
+ </button>
193
+ );
194
+ })}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ function formatValue(c: DiscountClaim): string {
202
+ if (c.discount.valueType === 'PERCENTAGE' && c.discount.valuePercentage != null) {
203
+ return `${100 - c.discount.valuePercentage} 折`;
204
+ }
205
+ if (c.discount.valueType === 'FIXED_AMOUNT' && c.discount.valueAmount) {
206
+ return `− ¥${c.discount.valueAmount.amount}`;
207
+ }
208
+ return '免运费';
209
+ }
@@ -116,18 +116,17 @@ export type {
116
116
  } from './DiscountProvider';
117
117
 
118
118
  export {
119
- DiscountCodeInput,
120
119
  AppliedDiscountList,
121
- BestDiscountHint,
122
120
  ClaimableDiscountList,
123
121
  DiscountClaimButton,
124
122
  MyDiscountList,
125
123
  } from './DiscountComponents';
126
124
  export type {
127
- DiscountCodeInputProps,
128
125
  AppliedDiscountListProps,
129
- BestDiscountHintProps,
130
126
  ClaimableDiscountListProps,
131
127
  DiscountClaimButtonProps,
132
128
  MyDiscountListProps,
133
129
  } from './DiscountComponents';
130
+
131
+ export { DiscountSelector } from './DiscountSelector';
132
+ export type { DiscountSelectorProps } from './DiscountSelector';