@shopbb/helium 0.3.1 → 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/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/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/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';
|