@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.
- package/dist/components/AddressBookProvider.d.ts +93 -0
- package/dist/components/AddressBookProvider.d.ts.map +1 -0
- package/dist/components/AddressBookProvider.js +182 -0
- package/dist/components/AddressBookProvider.js.map +1 -0
- package/dist/components/AddressForm.d.ts +54 -0
- package/dist/components/AddressForm.d.ts.map +1 -0
- package/dist/components/AddressForm.js +87 -0
- package/dist/components/AddressForm.js.map +1 -0
- package/dist/components/AddressList.d.ts +35 -0
- package/dist/components/AddressList.d.ts.map +1 -0
- package/dist/components/AddressList.js +40 -0
- package/dist/components/AddressList.js.map +1 -0
- package/dist/components/AddressPicker.d.ts +39 -0
- package/dist/components/AddressPicker.d.ts.map +1 -0
- package/dist/components/AddressPicker.js +74 -0
- package/dist/components/AddressPicker.js.map +1 -0
- package/dist/components/DiscountComponents.d.ts +66 -0
- package/dist/components/DiscountComponents.d.ts.map +1 -0
- package/dist/components/DiscountComponents.js +169 -0
- package/dist/components/DiscountComponents.js.map +1 -0
- package/dist/components/DiscountProvider.d.ts +143 -0
- package/dist/components/DiscountProvider.d.ts.map +1 -0
- package/dist/components/DiscountProvider.js +317 -0
- package/dist/components/DiscountProvider.js.map +1 -0
- package/dist/components/Money.d.ts.map +1 -1
- package/dist/components/Money.js +49 -31
- package/dist/components/Money.js.map +1 -1
- package/dist/components/index.d.ts +12 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +8 -0
- package/dist/components/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AddressBookProvider.tsx +279 -0
- package/src/components/AddressForm.tsx +198 -0
- package/src/components/AddressList.tsx +110 -0
- package/src/components/AddressPicker.tsx +152 -0
- package/src/components/DiscountComponents.tsx +369 -0
- package/src/components/DiscountProvider.tsx +455 -0
- package/src/components/Money.tsx +48 -31
- 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
|
+
}
|
package/src/components/Money.tsx
CHANGED
|
@@ -43,53 +43,70 @@ export interface MoneyProps extends React.HTMLAttributes<HTMLElement> {
|
|
|
43
43
|
measurement?: MoneyMeasurement;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
package/src/components/index.ts
CHANGED
|
@@ -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';
|