@shopbb/helium 0.3.0 → 0.4.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 (40) 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/DiscountComponents.d.ts +66 -0
  18. package/dist/components/DiscountComponents.d.ts.map +1 -0
  19. package/dist/components/DiscountComponents.js +169 -0
  20. package/dist/components/DiscountComponents.js.map +1 -0
  21. package/dist/components/DiscountProvider.d.ts +143 -0
  22. package/dist/components/DiscountProvider.d.ts.map +1 -0
  23. package/dist/components/DiscountProvider.js +317 -0
  24. package/dist/components/DiscountProvider.js.map +1 -0
  25. package/dist/components/Money.d.ts.map +1 -1
  26. package/dist/components/Money.js +49 -31
  27. package/dist/components/Money.js.map +1 -1
  28. package/dist/components/index.d.ts +12 -0
  29. package/dist/components/index.d.ts.map +1 -1
  30. package/dist/components/index.js +8 -0
  31. package/dist/components/index.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/components/AddressBookProvider.tsx +279 -0
  34. package/src/components/AddressForm.tsx +198 -0
  35. package/src/components/AddressList.tsx +110 -0
  36. package/src/components/AddressPicker.tsx +152 -0
  37. package/src/components/DiscountComponents.tsx +369 -0
  38. package/src/components/DiscountProvider.tsx +455 -0
  39. package/src/components/Money.tsx +48 -31
  40. package/src/components/index.ts +62 -0
@@ -0,0 +1,455 @@
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
+ /** 当前 cart 上应用的码 */
91
+ appliedToCart: CartDiscountCode[];
92
+ /** 当前 cart 上的折扣分配明细 */
93
+ cartAllocations: CartDiscountAllocation[];
94
+ /** 当前 cart 上最大可省(计算了用户已领但未应用的券) */
95
+ bestApplicableForCart: { discount: DiscountClaim; estimatedAmount: number } | null;
96
+ /** 错误 */
97
+ error: string | null;
98
+
99
+ // 操作
100
+ claim: (code: string) => Promise<{ claim?: DiscountClaim; userErrors: DiscountUserError[] }>;
101
+ applyToCart: (code: string) => Promise<{ userErrors: DiscountUserError[] }>;
102
+ removeFromCart: (code: string) => Promise<{ 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 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 }] };
301
+ }
302
+ // 刷 cart(拉新的 discountCodes / allocations)
303
+ await cartCtx.refetch();
304
+ return { userErrors: result.data!.cartDiscountCodesUpdate.userErrors };
305
+ },
306
+ [cartCtx, storefrontGql],
307
+ );
308
+
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);
348
+ }
349
+ if (amount > 0 && (best == null || amount > best.estimatedAmount)) {
350
+ best = { discount: claimed, estimatedAmount: amount };
351
+ }
352
+ }
353
+ return best;
354
+ }, [cartCtx, myDiscounts, appliedToCart]);
355
+
356
+ const value: DiscountContextValue = React.useMemo(
357
+ () => ({
358
+ publicDiscounts, publicDiscountsStatus,
359
+ myDiscounts, myDiscountsStatus,
360
+ appliedToCart, cartAllocations,
361
+ bestApplicableForCart,
362
+ error,
363
+ claim, applyToCart, removeFromCart,
364
+ refetchMyDiscounts, refetchPublicDiscounts,
365
+ }),
366
+ [
367
+ publicDiscounts, publicDiscountsStatus,
368
+ myDiscounts, myDiscountsStatus,
369
+ appliedToCart, cartAllocations,
370
+ bestApplicableForCart, error,
371
+ claim, applyToCart, removeFromCart,
372
+ refetchMyDiscounts, refetchPublicDiscounts,
373
+ ],
374
+ );
375
+
376
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
377
+ }
378
+
379
+ export function useDiscounts(): DiscountContextValue {
380
+ const v = React.useContext(Ctx);
381
+ if (!v) throw new Error('useDiscounts must be used inside <DiscountProvider>');
382
+ return v;
383
+ }
384
+
385
+ export function useDiscountsOptional(): DiscountContextValue | null {
386
+ return React.useContext(Ctx);
387
+ }
388
+
389
+ // ============================================================
390
+ // useProductDiscounts(handle) — 商品页用
391
+ // ============================================================
392
+
393
+ export function useProductDiscounts(productHandle: string | null | undefined): {
394
+ discounts: Discount[];
395
+ loading: boolean;
396
+ error: string | null;
397
+ } {
398
+ const shop = useShop();
399
+ const [discounts, setDiscounts] = React.useState<Discount[]>([]);
400
+ const [loading, setLoading] = React.useState(false);
401
+ const [error, setError] = React.useState<string | null>(null);
402
+
403
+ React.useEffect(() => {
404
+ if (!productHandle) {
405
+ setDiscounts([]);
406
+ return;
407
+ }
408
+ let cancelled = false;
409
+ setLoading(true);
410
+ (async () => {
411
+ try {
412
+ const res = await fetch(shop.apiUrl, {
413
+ method: 'POST',
414
+ headers: {
415
+ 'Content-Type': 'application/json',
416
+ 'X-Storefront-Access-Token': shop.storefrontAccessToken,
417
+ },
418
+ body: JSON.stringify({
419
+ query: `query P($h: String!) {
420
+ productDiscounts(productHandle: $h) {
421
+ id code title description
422
+ valueType
423
+ value {
424
+ __typename
425
+ ... on DiscountPercentage { percentage }
426
+ ... on DiscountAmount { amount { amount currencyCode } }
427
+ ... on DiscountFreeShipping { freeShipping }
428
+ }
429
+ minSubtotal { amount currencyCode }
430
+ endsAt
431
+ bannerImage { url }
432
+ }
433
+ }`,
434
+ variables: { h: productHandle },
435
+ }),
436
+ credentials: 'include',
437
+ });
438
+ const r: any = await res.json();
439
+ if (cancelled) return;
440
+ if (r.errors?.length) {
441
+ setError(r.errors[0].message);
442
+ } else {
443
+ setDiscounts(r.data?.productDiscounts ?? []);
444
+ }
445
+ } catch (e: any) {
446
+ if (!cancelled) setError(e?.message);
447
+ } finally {
448
+ if (!cancelled) setLoading(false);
449
+ }
450
+ })();
451
+ return () => { cancelled = true; };
452
+ }, [productHandle, shop.apiUrl, shop.storefrontAccessToken]);
453
+
454
+ return { discounts, loading, error };
455
+ }
@@ -43,53 +43,70 @@ export interface MoneyProps extends React.HTMLAttributes<HTMLElement> {
43
43
  measurement?: MoneyMeasurement;
44
44
  }
45
45
 
46
- // currencyCode → 默认 locale 推断
47
- const DEFAULT_LOCALE_BY_CURRENCY: Record<string, string> = {
48
- CNY: 'zh-CN',
49
- USD: 'en-US',
50
- EUR: 'de-DE',
51
- GBP: 'en-GB',
52
- JPY: 'ja-JP',
53
- KRW: 'ko-KR',
46
+ /**
47
+ * currency symbol。手写一张表,不用 Intl.NumberFormat —— 因为它在 Cloudflare Workers V8
48
+ * 和浏览器 V8 之间输出可能不同(货币符号位置、本地化分隔符),导致 SSR hydration mismatch。
49
+ *
50
+ * Shopify Hydrogen 的 <Money> 用同款手写方案,原因相同。
51
+ */
52
+ const CURRENCY_SYMBOL: Record<string, string> = {
53
+ CNY: '¥',
54
+ USD: '$',
55
+ EUR: '€',
56
+ GBP: '£',
57
+ JPY: '¥',
58
+ HKD: 'HK$',
59
+ KRW: '₩',
54
60
  };
55
61
 
62
+ /**
63
+ * 按 1000 分组加 thousand-separator。
64
+ * 中文/英文 locale 都是逗号;其他 locale 暂用逗号(如需 . 分隔,加 locale 表)。
65
+ */
66
+ function formatAmount(amount: number, minDecimals: number, maxDecimals: number): string {
67
+ if (!Number.isFinite(amount)) return '0';
68
+ const negative = amount < 0;
69
+ const abs = Math.abs(amount);
70
+ // 强制小数位数
71
+ const fixed = abs.toFixed(maxDecimals);
72
+ const [intPart, decPart = ''] = fixed.split('.');
73
+ // 加千分位
74
+ const intWithSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
75
+ // 去掉多余的尾零(保留最低 minDecimals 位)
76
+ let trimmedDec = decPart;
77
+ while (trimmedDec.length > minDecimals && trimmedDec.endsWith('0')) {
78
+ trimmedDec = trimmedDec.slice(0, -1);
79
+ }
80
+ const out = trimmedDec ? `${intWithSep}.${trimmedDec}` : intWithSep;
81
+ return negative ? `-${out}` : out;
82
+ }
83
+
56
84
  export function Money(props: MoneyProps) {
57
85
  const {
58
86
  data,
59
87
  withoutCurrency,
60
88
  withoutTrailingZeros,
61
- locale: localeProp,
89
+ locale: _localeProp,
62
90
  as,
63
91
  measurement,
64
92
  ...rest
65
93
  } = props;
66
94
 
67
95
  const Tag: any = as || 'span';
68
- const shop = useShopOptional();
69
- const locale =
70
- localeProp ||
71
- shop?.locale ||
72
- DEFAULT_LOCALE_BY_CURRENCY[data.currencyCode] ||
73
- 'en-US';
96
+ // locale 现在不影响输出(避免 SSR 不一致)。保留 prop 给未来可能扩展。
74
97
  const amount = Number(data.amount);
98
+ const minDec = withoutTrailingZeros && Number.isInteger(amount) ? 0 : 2;
99
+ const numStr = formatAmount(amount, minDec, 2);
100
+ const symbol = CURRENCY_SYMBOL[data.currencyCode];
75
101
 
76
102
  let formatted: string;
77
- try {
78
- const opts: Intl.NumberFormatOptions = withoutCurrency
79
- ? {
80
- minimumFractionDigits: withoutTrailingZeros && Number.isInteger(amount) ? 0 : 2,
81
- maximumFractionDigits: 2,
82
- }
83
- : {
84
- style: 'currency',
85
- currency: data.currencyCode,
86
- minimumFractionDigits: withoutTrailingZeros && Number.isInteger(amount) ? 0 : 2,
87
- maximumFractionDigits: 2,
88
- };
89
- formatted = new Intl.NumberFormat(locale, opts).format(amount);
90
- } catch {
91
- // Fallback:Intl 不支持的 currencyCode
92
- formatted = withoutCurrency ? amount.toFixed(2) : `${data.currencyCode} ${amount.toFixed(2)}`;
103
+ if (withoutCurrency) {
104
+ formatted = numStr;
105
+ } else if (symbol) {
106
+ formatted = `${symbol}${numStr}`;
107
+ } else {
108
+ // 未识别 currency code: 用 "USD 99.00" 形式
109
+ formatted = `${data.currencyCode} ${numStr}`;
93
110
  }
94
111
 
95
112
  // measurement suffix(unit price)
@@ -69,3 +69,65 @@ 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
+ DiscountCodeInput,
120
+ AppliedDiscountList,
121
+ BestDiscountHint,
122
+ ClaimableDiscountList,
123
+ DiscountClaimButton,
124
+ MyDiscountList,
125
+ } from './DiscountComponents';
126
+ export type {
127
+ DiscountCodeInputProps,
128
+ AppliedDiscountListProps,
129
+ BestDiscountHintProps,
130
+ ClaimableDiscountListProps,
131
+ DiscountClaimButtonProps,
132
+ MyDiscountListProps,
133
+ } from './DiscountComponents';