@shopbb/helium 0.3.1 → 0.5.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 (45) hide show
  1. package/dist/components/AddressBookProvider.d.ts +93 -0
  2. package/dist/components/AddressBookProvider.d.ts.map +1 -0
  3. package/dist/components/AddressBookProvider.js +182 -0
  4. package/dist/components/AddressBookProvider.js.map +1 -0
  5. package/dist/components/AddressForm.d.ts +54 -0
  6. package/dist/components/AddressForm.d.ts.map +1 -0
  7. package/dist/components/AddressForm.js +87 -0
  8. package/dist/components/AddressForm.js.map +1 -0
  9. package/dist/components/AddressList.d.ts +35 -0
  10. package/dist/components/AddressList.d.ts.map +1 -0
  11. package/dist/components/AddressList.js +40 -0
  12. package/dist/components/AddressList.js.map +1 -0
  13. package/dist/components/AddressPicker.d.ts +39 -0
  14. package/dist/components/AddressPicker.d.ts.map +1 -0
  15. package/dist/components/AddressPicker.js +74 -0
  16. package/dist/components/AddressPicker.js.map +1 -0
  17. package/dist/components/CartProvider.d.ts.map +1 -1
  18. package/dist/components/CartProvider.js +9 -0
  19. package/dist/components/CartProvider.js.map +1 -1
  20. package/dist/components/DiscountComponents.d.ts +49 -0
  21. package/dist/components/DiscountComponents.d.ts.map +1 -0
  22. package/dist/components/DiscountComponents.js +119 -0
  23. package/dist/components/DiscountComponents.js.map +1 -0
  24. package/dist/components/DiscountProvider.d.ts +136 -0
  25. package/dist/components/DiscountProvider.d.ts.map +1 -0
  26. package/dist/components/DiscountProvider.js +262 -0
  27. package/dist/components/DiscountProvider.js.map +1 -0
  28. package/dist/components/DiscountSelector.d.ts +36 -0
  29. package/dist/components/DiscountSelector.d.ts.map +1 -0
  30. package/dist/components/DiscountSelector.js +111 -0
  31. package/dist/components/DiscountSelector.js.map +1 -0
  32. package/dist/components/index.d.ts +14 -0
  33. package/dist/components/index.d.ts.map +1 -1
  34. package/dist/components/index.js +9 -0
  35. package/dist/components/index.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/AddressBookProvider.tsx +279 -0
  38. package/src/components/AddressForm.tsx +198 -0
  39. package/src/components/AddressList.tsx +110 -0
  40. package/src/components/AddressPicker.tsx +152 -0
  41. package/src/components/CartProvider.tsx +9 -0
  42. package/src/components/DiscountComponents.tsx +253 -0
  43. package/src/components/DiscountProvider.tsx +390 -0
  44. package/src/components/DiscountSelector.tsx +220 -0
  45. package/src/components/index.ts +61 -0
@@ -0,0 +1,390 @@
1
+ /**
2
+ * <DiscountProvider> + useDiscounts() + useProductDiscounts()
3
+ *
4
+ * 优惠券完整能力:
5
+ * - publicDiscounts: 公开可领的券(storefront 首页 / cart 展示)
6
+ * - myDiscounts: 我领过的有效券(卡包)
7
+ * - claim(code): 领取
8
+ * - cart 上的折扣:appliedToCart / applyToCart / removeFromCart
9
+ * - bestApplicableForCart: 当前 cart 最大可省(哪张最划算)
10
+ *
11
+ * 内部走 Storefront GraphQL + Customer Account GraphQL。
12
+ */
13
+
14
+ import * as React from 'react';
15
+ import { useShop } from './ShopProvider';
16
+ import { useCartOptional } from './CartProvider';
17
+
18
+ // ============================================================
19
+ // Types
20
+ // ============================================================
21
+
22
+ export type DiscountValueType = 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_SHIPPING';
23
+
24
+ export interface DiscountValuePercentage { __typename: 'DiscountPercentage'; percentage: number; }
25
+ export interface DiscountValueAmount { __typename: 'DiscountAmount'; amount: { amount: string; currencyCode: string }; }
26
+ export interface DiscountValueFreeShipping { __typename: 'DiscountFreeShipping'; freeShipping: boolean; }
27
+
28
+ export interface Discount {
29
+ id: string;
30
+ code: string | null;
31
+ title: string;
32
+ description: string | null;
33
+ valueType: DiscountValueType;
34
+ value: DiscountValuePercentage | DiscountValueAmount | DiscountValueFreeShipping;
35
+ appliesTo: 'ALL' | 'PRODUCTS' | 'COLLECTIONS';
36
+ minSubtotal: { amount: string; currencyCode: string } | null;
37
+ startsAt: string | null;
38
+ endsAt: string | null;
39
+ bannerImage: { url: string; altText?: string | null } | null;
40
+ distribution: 'PUBLIC' | 'MANUAL' | 'AUTO';
41
+ }
42
+
43
+ export interface DiscountClaim {
44
+ id: string;
45
+ claimedAt: string;
46
+ usedCount: number;
47
+ remainingUses: number;
48
+ expiresAt: string | null;
49
+ isExpired: boolean;
50
+ discount: {
51
+ id: string;
52
+ code: string | null;
53
+ title: string;
54
+ description: string | null;
55
+ valueType: DiscountValueType;
56
+ valuePercentage: number | null;
57
+ valueAmount: { amount: string; currencyCode: string } | null;
58
+ minSubtotal: { amount: string; currencyCode: string } | null;
59
+ endsAt: string | null;
60
+ bannerImage: string | null;
61
+ };
62
+ }
63
+
64
+ export interface CartDiscountCode {
65
+ code: string;
66
+ applicable: boolean;
67
+ }
68
+
69
+ export interface CartDiscountAllocation {
70
+ discountedAmount: { amount: string; currencyCode: string };
71
+ targetType: 'CART' | 'LINE';
72
+ targetId: string | null;
73
+ title: string;
74
+ code: string | null;
75
+ }
76
+
77
+ export interface DiscountUserError {
78
+ field?: string[];
79
+ code?: string;
80
+ message: string;
81
+ }
82
+
83
+ export interface DiscountContextValue {
84
+ /** 公开可领的券 */
85
+ publicDiscounts: Discount[];
86
+ publicDiscountsStatus: 'idle' | 'loading' | 'error';
87
+ /** 我领过的有效券 */
88
+ myDiscounts: DiscountClaim[];
89
+ myDiscountsStatus: 'unauthenticated' | 'idle' | 'loading' | 'error';
90
+ /**
91
+ * 当前 cart 上的折扣分配明细。
92
+ * W5 起:cart 上最多一张券(自动选最佳 / 买家手动选)。
93
+ * 数组语义保留兼容(未来可能允许多张叠加)。
94
+ */
95
+ cartAllocations: CartDiscountAllocation[];
96
+ /** 当前应用的 claim ID(W5),null = 无折扣或买家未登录 */
97
+ appliedClaimId: string | null;
98
+ /** 错误 */
99
+ error: string | null;
100
+
101
+ // 操作
102
+ claim: (code: string) => Promise<{ claim?: DiscountClaim; userErrors: DiscountUserError[] }>;
103
+ refetchMyDiscounts: () => Promise<void>;
104
+ refetchPublicDiscounts: () => Promise<void>;
105
+ }
106
+
107
+ const Ctx = React.createContext<DiscountContextValue | null>(null);
108
+
109
+ // ============================================================
110
+ // Provider
111
+ // ============================================================
112
+
113
+ export interface DiscountProviderProps {
114
+ children: React.ReactNode;
115
+ /** 自动 fetch publicDiscounts;默认 true */
116
+ fetchPublicOnMount?: boolean;
117
+ /** 自动 fetch myDiscounts(登录买家);默认 true */
118
+ fetchMyOnMount?: boolean;
119
+ /** 自定义 token */
120
+ tokenProvider?: () => string | null;
121
+ }
122
+
123
+ const DEFAULT_TOKEN_KEY = 'shopbb:buyer_token';
124
+
125
+ export function DiscountProvider(props: DiscountProviderProps) {
126
+ const { children, fetchPublicOnMount = true, fetchMyOnMount = true, tokenProvider } = props;
127
+ const shop = useShop();
128
+ const cartCtx = useCartOptional();
129
+
130
+ const [publicDiscounts, setPublicDiscounts] = React.useState<Discount[]>([]);
131
+ const [publicDiscountsStatus, setPublicDiscountsStatus] = React.useState<'idle' | 'loading' | 'error'>('idle');
132
+ const [myDiscounts, setMyDiscounts] = React.useState<DiscountClaim[]>([]);
133
+ const [myDiscountsStatus, setMyDiscountsStatus] = React.useState<'unauthenticated' | 'idle' | 'loading' | 'error'>('idle');
134
+ const [error, setError] = React.useState<string | null>(null);
135
+
136
+ const customerEndpoint = React.useMemo(() => {
137
+ try {
138
+ const u = new URL(shop.apiUrl);
139
+ return `${u.origin}/customer/api/2026-04/graphql`;
140
+ } catch {
141
+ return shop.apiUrl;
142
+ }
143
+ }, [shop.apiUrl]);
144
+
145
+ const getToken = React.useCallback(() => {
146
+ if (tokenProvider) return tokenProvider();
147
+ if (typeof localStorage === 'undefined') return null;
148
+ return localStorage.getItem(DEFAULT_TOKEN_KEY) || localStorage.getItem('shopflare:buyer_token');
149
+ }, [tokenProvider]);
150
+
151
+ const storefrontGql = React.useCallback(
152
+ async <T = any>(query: string, variables?: any): Promise<{ data?: T; errors?: any[] }> => {
153
+ const res = await fetch(shop.apiUrl, {
154
+ method: 'POST',
155
+ headers: {
156
+ 'Content-Type': 'application/json',
157
+ 'X-Storefront-Access-Token': shop.storefrontAccessToken,
158
+ },
159
+ body: JSON.stringify({ query, variables }),
160
+ credentials: 'include',
161
+ });
162
+ return res.json();
163
+ },
164
+ [shop.apiUrl, shop.storefrontAccessToken],
165
+ );
166
+
167
+ const customerGql = React.useCallback(
168
+ async <T = any>(query: string, variables?: any): Promise<{ data?: T; errors?: any[] }> => {
169
+ const token = getToken();
170
+ if (!token) throw new Error('not authenticated');
171
+ const res = await fetch(customerEndpoint, {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
174
+ body: JSON.stringify({ query, variables }),
175
+ });
176
+ return res.json();
177
+ },
178
+ [customerEndpoint, getToken],
179
+ );
180
+
181
+ // ----- public discounts -----
182
+ const refetchPublicDiscounts = React.useCallback(async () => {
183
+ setPublicDiscountsStatus('loading');
184
+ try {
185
+ const result = await storefrontGql<{ publicDiscounts: { nodes: Discount[] } }>(
186
+ `query { publicDiscounts(first: 50) {
187
+ nodes {
188
+ id code title description distribution appliesTo
189
+ valueType
190
+ value {
191
+ __typename
192
+ ... on DiscountPercentage { percentage }
193
+ ... on DiscountAmount { amount { amount currencyCode } }
194
+ ... on DiscountFreeShipping { freeShipping }
195
+ }
196
+ minSubtotal { amount currencyCode }
197
+ startsAt endsAt
198
+ bannerImage { url altText }
199
+ }
200
+ } }`,
201
+ );
202
+ if (result.errors?.length) throw new Error(result.errors[0].message);
203
+ setPublicDiscounts(result.data?.publicDiscounts?.nodes ?? []);
204
+ setPublicDiscountsStatus('idle');
205
+ } catch (e: any) {
206
+ setError(e?.message);
207
+ setPublicDiscountsStatus('error');
208
+ }
209
+ }, [storefrontGql]);
210
+
211
+ // ----- my discounts -----
212
+ const refetchMyDiscounts = React.useCallback(async () => {
213
+ const token = getToken();
214
+ if (!token) {
215
+ setMyDiscountsStatus('unauthenticated');
216
+ setMyDiscounts([]);
217
+ return;
218
+ }
219
+ setMyDiscountsStatus('loading');
220
+ try {
221
+ const result = await customerGql<{ customer: { discountClaims: { nodes: DiscountClaim[] } } | null }>(
222
+ `query { customer { discountClaims(first: 50) {
223
+ nodes {
224
+ id claimedAt usedCount remainingUses expiresAt isExpired
225
+ discount {
226
+ id code title description
227
+ valueType valuePercentage
228
+ valueAmount { amount currencyCode }
229
+ minSubtotal { amount currencyCode }
230
+ endsAt bannerImage
231
+ }
232
+ }
233
+ } } }`,
234
+ );
235
+ if (result.errors?.length) throw new Error(result.errors[0].message);
236
+ setMyDiscounts(result.data?.customer?.discountClaims?.nodes ?? []);
237
+ setMyDiscountsStatus('idle');
238
+ } catch (e: any) {
239
+ setError(e?.message);
240
+ setMyDiscountsStatus('error');
241
+ }
242
+ }, [customerGql, getToken]);
243
+
244
+ React.useEffect(() => {
245
+ if (fetchPublicOnMount) void refetchPublicDiscounts();
246
+ }, [fetchPublicOnMount, refetchPublicDiscounts]);
247
+
248
+ React.useEffect(() => {
249
+ if (fetchMyOnMount) void refetchMyDiscounts();
250
+ }, [fetchMyOnMount, refetchMyDiscounts]);
251
+
252
+ // ----- claim -----
253
+ const claim = React.useCallback(
254
+ async (code: string) => {
255
+ try {
256
+ const result = await customerGql<{ discountClaim: { discountClaim: DiscountClaim | null; userErrors: DiscountUserError[] } }>(
257
+ `mutation C($code: String!) {
258
+ discountClaim(code: $code) {
259
+ discountClaim {
260
+ id claimedAt usedCount remainingUses expiresAt isExpired
261
+ discount {
262
+ id code title valueType valuePercentage
263
+ valueAmount { amount currencyCode }
264
+ minSubtotal { amount currencyCode }
265
+ }
266
+ }
267
+ userErrors { field code message }
268
+ }
269
+ }`,
270
+ { code },
271
+ );
272
+ if (result.errors?.length) throw new Error(result.errors[0].message);
273
+ await refetchMyDiscounts();
274
+ const payload = result.data!.discountClaim;
275
+ return { claim: payload.discountClaim ?? undefined, userErrors: payload.userErrors };
276
+ } catch (e: any) {
277
+ return { userErrors: [{ code: 'NETWORK_ERROR', message: e?.message ?? String(e) }] };
278
+ }
279
+ },
280
+ [customerGql, refetchMyDiscounts],
281
+ );
282
+
283
+ // ----- 当前 cart 上的折扣(服务端算好的) -----
284
+ const cartAllocations = cartCtx?.cart && (cartCtx.cart as any).discountAllocations
285
+ ? (cartCtx.cart as any).discountAllocations as CartDiscountAllocation[]
286
+ : [];
287
+ // W5: 服务端的 appliedDiscountClaim(哪张被选中应用了)
288
+ const appliedClaimId: string | null = cartCtx?.cart && (cartCtx.cart as any).appliedDiscountClaim?.claimId
289
+ ? (cartCtx.cart as any).appliedDiscountClaim.claimId
290
+ : null;
291
+
292
+ const value: DiscountContextValue = React.useMemo(
293
+ () => ({
294
+ publicDiscounts, publicDiscountsStatus,
295
+ myDiscounts, myDiscountsStatus,
296
+ cartAllocations,
297
+ appliedClaimId,
298
+ error,
299
+ claim,
300
+ refetchMyDiscounts, refetchPublicDiscounts,
301
+ }),
302
+ [
303
+ publicDiscounts, publicDiscountsStatus,
304
+ myDiscounts, myDiscountsStatus,
305
+ cartAllocations, appliedClaimId, error,
306
+ claim,
307
+ refetchMyDiscounts, refetchPublicDiscounts,
308
+ ],
309
+ );
310
+
311
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
312
+ }
313
+
314
+ export function useDiscounts(): DiscountContextValue {
315
+ const v = React.useContext(Ctx);
316
+ if (!v) throw new Error('useDiscounts must be used inside <DiscountProvider>');
317
+ return v;
318
+ }
319
+
320
+ export function useDiscountsOptional(): DiscountContextValue | null {
321
+ return React.useContext(Ctx);
322
+ }
323
+
324
+ // ============================================================
325
+ // useProductDiscounts(handle) — 商品页用
326
+ // ============================================================
327
+
328
+ export function useProductDiscounts(productHandle: string | null | undefined): {
329
+ discounts: Discount[];
330
+ loading: boolean;
331
+ error: string | null;
332
+ } {
333
+ const shop = useShop();
334
+ const [discounts, setDiscounts] = React.useState<Discount[]>([]);
335
+ const [loading, setLoading] = React.useState(false);
336
+ const [error, setError] = React.useState<string | null>(null);
337
+
338
+ React.useEffect(() => {
339
+ if (!productHandle) {
340
+ setDiscounts([]);
341
+ return;
342
+ }
343
+ let cancelled = false;
344
+ setLoading(true);
345
+ (async () => {
346
+ try {
347
+ const res = await fetch(shop.apiUrl, {
348
+ method: 'POST',
349
+ headers: {
350
+ 'Content-Type': 'application/json',
351
+ 'X-Storefront-Access-Token': shop.storefrontAccessToken,
352
+ },
353
+ body: JSON.stringify({
354
+ query: `query P($h: String!) {
355
+ productDiscounts(productHandle: $h) {
356
+ id code title description
357
+ valueType
358
+ value {
359
+ __typename
360
+ ... on DiscountPercentage { percentage }
361
+ ... on DiscountAmount { amount { amount currencyCode } }
362
+ ... on DiscountFreeShipping { freeShipping }
363
+ }
364
+ minSubtotal { amount currencyCode }
365
+ endsAt
366
+ bannerImage { url }
367
+ }
368
+ }`,
369
+ variables: { h: productHandle },
370
+ }),
371
+ credentials: 'include',
372
+ });
373
+ const r: any = await res.json();
374
+ if (cancelled) return;
375
+ if (r.errors?.length) {
376
+ setError(r.errors[0].message);
377
+ } else {
378
+ setDiscounts(r.data?.productDiscounts ?? []);
379
+ }
380
+ } catch (e: any) {
381
+ if (!cancelled) setError(e?.message);
382
+ } finally {
383
+ if (!cancelled) setLoading(false);
384
+ }
385
+ })();
386
+ return () => { cancelled = true; };
387
+ }, [productHandle, shop.apiUrl, shop.storefrontAccessToken]);
388
+
389
+ return { discounts, loading, error };
390
+ }
@@ -0,0 +1,220 @@
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 { useCart } from './CartProvider';
22
+ import { useShop } from './ShopProvider';
23
+ import { useAnalytics } from './AnalyticsProvider';
24
+ import { Money } from './Money';
25
+
26
+ export interface DiscountSelectorProps {
27
+ className?: string;
28
+ /** 未登录时显示的内容;不传则显示默认 */
29
+ unauthenticatedFallback?: React.ReactNode;
30
+ /** 自定义渲染 */
31
+ renderApplied?: (props: { code: string | null; title: string; amount: { amount: string; currencyCode: string }; onChange: () => void; onClear: () => void }) => React.ReactNode;
32
+ }
33
+
34
+ export function DiscountSelector(props: DiscountSelectorProps) {
35
+ const { className, unauthenticatedFallback, renderApplied } = props;
36
+ const { myDiscounts, myDiscountsStatus, cartAllocations } = useDiscounts();
37
+ const { cart, refetch: refetchCart } = useCart();
38
+ const shop = useShop();
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 = (cart as any)?.appliedDiscountClaim?.claimId ?? null;
47
+ const appliedTitle = (cart as any)?.appliedDiscountClaim?.title ?? appliedAlloc?.title;
48
+ const appliedCode = (cart as any)?.appliedDiscountClaim?.code ?? appliedAlloc?.code;
49
+
50
+ const callSelectMutation = async (claimId: string | null) => {
51
+ if (!cart) return;
52
+ setPending(true);
53
+ try {
54
+ const token = (typeof localStorage !== 'undefined') ? (localStorage.getItem('shopbb:buyer_token') || localStorage.getItem('shopflare:buyer_token')) : null;
55
+ const mutation = claimId
56
+ ? `mutation S($id: ID!, $c: ID!) { cartDiscountSelect(cartId: $id, claimId: $c) { cart { id } userErrors { message } } }`
57
+ : `mutation C($id: ID!) { cartDiscountClear(cartId: $id) { cart { id } userErrors { message } } }`;
58
+ const variables: any = { id: cart.id };
59
+ if (claimId) variables.c = claimId;
60
+ const res = await fetch(shop.apiUrl, {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Content-Type': 'application/json',
64
+ 'X-Storefront-Access-Token': shop.storefrontAccessToken,
65
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
66
+ },
67
+ body: JSON.stringify({ query: mutation, variables }),
68
+ credentials: 'include',
69
+ });
70
+ await res.json();
71
+ // 重新拉 cart 让 UI 更新(拿到新的 discountAllocations)
72
+ await refetchCart();
73
+ } finally {
74
+ setPending(false);
75
+ setOpen(false);
76
+ }
77
+ };
78
+
79
+ // 未登录
80
+ if (myDiscountsStatus === 'unauthenticated') {
81
+ return unauthenticatedFallback !== undefined ? <>{unauthenticatedFallback}</> : (
82
+ <div data-discount-selector data-unauth className={className}>
83
+ <span>登录后可使用优惠券</span>
84
+ <a href="/login?next=/cart">去登录</a>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ if (myDiscountsStatus === 'loading') {
90
+ return <div data-discount-selector data-loading className={className}>加载优惠券…</div>;
91
+ }
92
+
93
+ const availableClaims = myDiscounts.filter((c) => !c.isExpired && c.remainingUses > 0);
94
+
95
+ if (availableClaims.length === 0) {
96
+ return (
97
+ <div data-discount-selector data-no-discount className={className}>
98
+ <span>暂无可用优惠券</span>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <>
105
+ <div data-discount-selector className={className}>
106
+ {appliedClaimId ? (
107
+ renderApplied ? renderApplied({
108
+ code: appliedCode ?? null,
109
+ title: appliedTitle ?? '',
110
+ amount: appliedAlloc?.discountedAmount ?? { amount: '0', currencyCode: 'CNY' },
111
+ onChange: () => setOpen(true),
112
+ onClear: () => callSelectMutation(null),
113
+ }) : (
114
+ <div data-applied>
115
+ <div data-info>
116
+ <span data-label>已使用</span>
117
+ <strong data-title>{appliedTitle}</strong>
118
+ {appliedAlloc && (
119
+ <span data-amount>− <Money data={{ amount: appliedAlloc.discountedAmount.amount, currencyCode: appliedAlloc.discountedAmount.currencyCode }} /></span>
120
+ )}
121
+ </div>
122
+ <div data-actions>
123
+ <button type="button" onClick={() => setOpen(true)} disabled={pending}>切换</button>
124
+ <button type="button" onClick={() => callSelectMutation(null)} disabled={pending} data-clear>不使用</button>
125
+ </div>
126
+ </div>
127
+ )
128
+ ) : (
129
+ <div data-empty-applied>
130
+ <div data-info>
131
+ <span data-label>有 {availableClaims.length} 张可用优惠券</span>
132
+ </div>
133
+ <button type="button" onClick={() => setOpen(true)} disabled={pending}>选择</button>
134
+ </div>
135
+ )}
136
+ </div>
137
+
138
+ {open && (
139
+ <DiscountPickerSheet
140
+ claims={availableClaims}
141
+ currentClaimId={appliedClaimId}
142
+ onSelect={(claimId) => {
143
+ analytics.emit('discount_select', { claimId });
144
+ callSelectMutation(claimId);
145
+ }}
146
+ onClose={() => setOpen(false)}
147
+ pending={pending}
148
+ />
149
+ )}
150
+ </>
151
+ );
152
+ }
153
+
154
+ /**
155
+ * 抽屉式选券面板。
156
+ * Demo 实现为屏底固定面板;商家可以替换为 modal / 抽屉等。
157
+ */
158
+ function DiscountPickerSheet({
159
+ claims, currentClaimId, onSelect, onClose, pending,
160
+ }: {
161
+ claims: DiscountClaim[];
162
+ currentClaimId: string | null;
163
+ onSelect: (claimId: string) => void;
164
+ onClose: () => void;
165
+ pending: boolean;
166
+ }) {
167
+ return (
168
+ <div data-discount-picker-overlay onClick={onClose}>
169
+ <div data-discount-picker-sheet onClick={(e) => e.stopPropagation()}>
170
+ <div data-sheet-header>
171
+ <h3>选择优惠券</h3>
172
+ <button type="button" onClick={onClose} data-sheet-close>×</button>
173
+ </div>
174
+ <div data-sheet-list>
175
+ {claims.map((c) => {
176
+ const id = c.id.replace(/^gid:\/\/shopbb\/DiscountClaim\//, '');
177
+ const isCurrent = currentClaimId === id;
178
+ return (
179
+ <button
180
+ key={c.id}
181
+ type="button"
182
+ data-claim-card
183
+ data-current={isCurrent ? '' : undefined}
184
+ onClick={() => onSelect(id)}
185
+ disabled={pending}
186
+ >
187
+ <div data-claim-left>
188
+ <div data-claim-value>{formatValue(c)}</div>
189
+ {c.discount.minSubtotal && (
190
+ <div data-claim-min>
191
+ 满 <Money data={{ amount: c.discount.minSubtotal.amount, currencyCode: c.discount.minSubtotal.currencyCode }} /> 可用
192
+ </div>
193
+ )}
194
+ </div>
195
+ <div data-claim-right>
196
+ <div data-claim-title>{c.discount.title}</div>
197
+ {c.discount.code && <div data-claim-code>{c.discount.code}</div>}
198
+ {c.expiresAt && (
199
+ <div data-claim-expiry>{new Date(c.expiresAt).toLocaleDateString('zh-CN')} 过期</div>
200
+ )}
201
+ </div>
202
+ {isCurrent && <span data-claim-current-tag>当前使用</span>}
203
+ </button>
204
+ );
205
+ })}
206
+ </div>
207
+ </div>
208
+ </div>
209
+ );
210
+ }
211
+
212
+ function formatValue(c: DiscountClaim): string {
213
+ if (c.discount.valueType === 'PERCENTAGE' && c.discount.valuePercentage != null) {
214
+ return `${100 - c.discount.valuePercentage} 折`;
215
+ }
216
+ if (c.discount.valueType === 'FIXED_AMOUNT' && c.discount.valueAmount) {
217
+ return `− ¥${c.discount.valueAmount.amount}`;
218
+ }
219
+ return '免运费';
220
+ }
@@ -69,3 +69,64 @@ export type {
69
69
  VariantSelectorRenderProps,
70
70
  VariantOption,
71
71
  } from './VariantSelector';
72
+
73
+ // 地址簿(W4a)
74
+ export {
75
+ AddressBookProvider,
76
+ useAddressBook,
77
+ useAddressBookOptional,
78
+ } from './AddressBookProvider';
79
+ export type {
80
+ Address,
81
+ AddressInput,
82
+ AddressBookStatus,
83
+ AddressBookUserError,
84
+ AddressBookContextValue,
85
+ AddressBookProviderProps,
86
+ } from './AddressBookProvider';
87
+
88
+ export { AddressList } from './AddressList';
89
+ export type { AddressListProps, AddressListItemActions } from './AddressList';
90
+
91
+ export { AddressForm } from './AddressForm';
92
+ export type { AddressFormProps, AddressFormI18n } from './AddressForm';
93
+
94
+ export { AddressPicker } from './AddressPicker';
95
+ export type { AddressPickerProps } from './AddressPicker';
96
+
97
+ // 优惠券(W4b)
98
+ export {
99
+ DiscountProvider,
100
+ useDiscounts,
101
+ useDiscountsOptional,
102
+ useProductDiscounts,
103
+ } from './DiscountProvider';
104
+ export type {
105
+ Discount,
106
+ DiscountClaim,
107
+ DiscountValueType,
108
+ DiscountValuePercentage,
109
+ DiscountValueAmount,
110
+ DiscountValueFreeShipping,
111
+ CartDiscountCode,
112
+ CartDiscountAllocation,
113
+ DiscountUserError,
114
+ DiscountContextValue,
115
+ DiscountProviderProps,
116
+ } from './DiscountProvider';
117
+
118
+ export {
119
+ AppliedDiscountList,
120
+ ClaimableDiscountList,
121
+ DiscountClaimButton,
122
+ MyDiscountList,
123
+ } from './DiscountComponents';
124
+ export type {
125
+ AppliedDiscountListProps,
126
+ ClaimableDiscountListProps,
127
+ DiscountClaimButtonProps,
128
+ MyDiscountListProps,
129
+ } from './DiscountComponents';
130
+
131
+ export { DiscountSelector } from './DiscountSelector';
132
+ export type { DiscountSelectorProps } from './DiscountSelector';