@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,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discount UI 组件集(W4b)
|
|
3
|
+
*
|
|
4
|
+
* <DiscountCodeInput> cart / checkout 输入框
|
|
5
|
+
* <AppliedDiscountList> 当前 cart 已应用的码列表
|
|
6
|
+
* <BestDiscountHint> "可省 ¥X" 提示
|
|
7
|
+
* <ClaimableDiscountList> 可领取的优惠券列表(首页 / 商品页)
|
|
8
|
+
* <DiscountClaimButton> 单个领取按钮
|
|
9
|
+
* <MyDiscountList> 我的卡包
|
|
10
|
+
*
|
|
11
|
+
* 全部无样式 + data-* 钩子。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import { useDiscounts, useProductDiscounts, type Discount, type DiscountClaim } from './DiscountProvider';
|
|
16
|
+
import { useAnalytics } from './AnalyticsProvider';
|
|
17
|
+
import { Money } from './Money';
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// 1. DiscountCodeInput
|
|
21
|
+
// ============================================================
|
|
22
|
+
|
|
23
|
+
export interface DiscountCodeInputProps {
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
buttonText?: string;
|
|
26
|
+
onApplied?: (code: string) => void;
|
|
27
|
+
onError?: (msg: string) => void;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function DiscountCodeInput(props: DiscountCodeInputProps) {
|
|
32
|
+
const { placeholder = '输入优惠码', buttonText = '应用', onApplied, onError, className } = props;
|
|
33
|
+
const { applyToCart } = useDiscounts();
|
|
34
|
+
const analytics = useAnalytics();
|
|
35
|
+
const [code, setCode] = React.useState('');
|
|
36
|
+
const [loading, setLoading] = React.useState(false);
|
|
37
|
+
const [err, setErr] = React.useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
const submit = async () => {
|
|
40
|
+
const c = code.trim();
|
|
41
|
+
if (!c) return;
|
|
42
|
+
setLoading(true);
|
|
43
|
+
setErr(null);
|
|
44
|
+
try {
|
|
45
|
+
const r = await applyToCart(c);
|
|
46
|
+
if (r.userErrors.length > 0) {
|
|
47
|
+
const msg = r.userErrors[0].message;
|
|
48
|
+
setErr(msg);
|
|
49
|
+
onError?.(msg);
|
|
50
|
+
} else {
|
|
51
|
+
setCode('');
|
|
52
|
+
onApplied?.(c);
|
|
53
|
+
analytics.emit('discount_apply_to_cart', { code: c });
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<form
|
|
62
|
+
data-discount-code-input
|
|
63
|
+
className={className}
|
|
64
|
+
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
|
65
|
+
>
|
|
66
|
+
<div data-row>
|
|
67
|
+
<input
|
|
68
|
+
value={code}
|
|
69
|
+
onChange={(e) => setCode(e.target.value)}
|
|
70
|
+
placeholder={placeholder}
|
|
71
|
+
disabled={loading}
|
|
72
|
+
/>
|
|
73
|
+
<button type="submit" disabled={loading || !code.trim()}>
|
|
74
|
+
{loading ? '...' : buttonText}
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
{err && <div data-error>{err}</div>}
|
|
78
|
+
</form>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================
|
|
83
|
+
// 2. AppliedDiscountList
|
|
84
|
+
// ============================================================
|
|
85
|
+
|
|
86
|
+
export interface AppliedDiscountListProps {
|
|
87
|
+
className?: string;
|
|
88
|
+
emptyText?: React.ReactNode;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function AppliedDiscountList(props: AppliedDiscountListProps) {
|
|
92
|
+
const { className, emptyText = null } = props;
|
|
93
|
+
const { appliedToCart, cartAllocations, removeFromCart } = useDiscounts();
|
|
94
|
+
const analytics = useAnalytics();
|
|
95
|
+
|
|
96
|
+
if (appliedToCart.length === 0) return <>{emptyText}</>;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div data-applied-discount-list className={className}>
|
|
100
|
+
{appliedToCart.map((dc) => {
|
|
101
|
+
const alloc = cartAllocations.find((a) => a.code === dc.code);
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
key={dc.code}
|
|
105
|
+
data-applied-item
|
|
106
|
+
data-applicable={dc.applicable ? '' : undefined}
|
|
107
|
+
>
|
|
108
|
+
<div data-info>
|
|
109
|
+
<span data-code>{dc.code}</span>
|
|
110
|
+
{alloc && (
|
|
111
|
+
<span data-discount-amount>
|
|
112
|
+
− <Money data={{ amount: alloc.discountedAmount.amount, currencyCode: alloc.discountedAmount.currencyCode }} />
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
{!dc.applicable && <span data-warning>不适用</span>}
|
|
116
|
+
</div>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
data-remove
|
|
120
|
+
onClick={async () => {
|
|
121
|
+
await removeFromCart(dc.code);
|
|
122
|
+
analytics.emit('discount_remove_from_cart', { code: dc.code });
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
×
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============================================================
|
|
135
|
+
// 3. BestDiscountHint
|
|
136
|
+
// ============================================================
|
|
137
|
+
|
|
138
|
+
export interface BestDiscountHintProps {
|
|
139
|
+
className?: string;
|
|
140
|
+
/** 自定义渲染(高级用法) */
|
|
141
|
+
render?: (best: { discount: DiscountClaim; estimatedAmount: number; apply: () => Promise<void> }) => React.ReactNode;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function BestDiscountHint(props: BestDiscountHintProps) {
|
|
145
|
+
const { className, render } = props;
|
|
146
|
+
const { bestApplicableForCart, applyToCart } = useDiscounts();
|
|
147
|
+
const analytics = useAnalytics();
|
|
148
|
+
if (!bestApplicableForCart) return null;
|
|
149
|
+
const { discount: claim, estimatedAmount } = bestApplicableForCart;
|
|
150
|
+
const code = claim.discount.code;
|
|
151
|
+
if (!code) return null;
|
|
152
|
+
|
|
153
|
+
const apply = async () => {
|
|
154
|
+
await applyToCart(code);
|
|
155
|
+
analytics.emit('discount_apply_to_cart', { code, source: 'best_hint' });
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (render) return <>{render({ discount: claim, estimatedAmount, apply })}</>;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div data-best-discount-hint className={className}>
|
|
162
|
+
<div data-msg>
|
|
163
|
+
使用 <strong data-code>{code}</strong> 可省{' '}
|
|
164
|
+
<strong data-amount><Money data={{ amount: String(estimatedAmount), currencyCode: 'CNY' }} /></strong>
|
|
165
|
+
</div>
|
|
166
|
+
<button type="button" onClick={apply} data-apply>立即使用</button>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================
|
|
172
|
+
// 4. ClaimableDiscountList
|
|
173
|
+
// ============================================================
|
|
174
|
+
|
|
175
|
+
export interface ClaimableDiscountListProps {
|
|
176
|
+
scope?: 'store' | 'product';
|
|
177
|
+
/** scope='product' 时必传 */
|
|
178
|
+
productHandle?: string;
|
|
179
|
+
first?: number;
|
|
180
|
+
className?: string;
|
|
181
|
+
emptyText?: React.ReactNode;
|
|
182
|
+
/** 自定义渲染(高级用法) */
|
|
183
|
+
renderItem?: (discount: Discount) => React.ReactNode;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function ClaimableDiscountList(props: ClaimableDiscountListProps) {
|
|
187
|
+
const { scope = 'store', productHandle, first = 10, className, emptyText = null, renderItem } = props;
|
|
188
|
+
const { publicDiscounts, publicDiscountsStatus } = useDiscounts();
|
|
189
|
+
const { discounts: productDiscs, loading: productLoading } = useProductDiscounts(scope === 'product' ? productHandle : null);
|
|
190
|
+
|
|
191
|
+
const list = scope === 'product' ? productDiscs : publicDiscounts;
|
|
192
|
+
const loading = scope === 'product' ? productLoading : publicDiscountsStatus === 'loading';
|
|
193
|
+
|
|
194
|
+
if (loading) return null;
|
|
195
|
+
if (list.length === 0) return <>{emptyText}</>;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div data-claimable-discount-list data-scope={scope} className={className}>
|
|
199
|
+
{list.slice(0, first).map((d) => (
|
|
200
|
+
<React.Fragment key={d.id}>
|
|
201
|
+
{renderItem ? renderItem(d) : <DefaultClaimableItem discount={d} />}
|
|
202
|
+
</React.Fragment>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function DefaultClaimableItem({ discount: d }: { discount: Discount }) {
|
|
209
|
+
return (
|
|
210
|
+
<div data-claimable-item>
|
|
211
|
+
<div data-info>
|
|
212
|
+
<div data-title>{d.title}</div>
|
|
213
|
+
<div data-value>{formatDiscountValue(d)}</div>
|
|
214
|
+
{d.minSubtotal && (
|
|
215
|
+
<div data-condition>
|
|
216
|
+
满 <Money data={{ amount: d.minSubtotal.amount, currencyCode: d.minSubtotal.currencyCode }} /> 可用
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
{d.endsAt && (
|
|
220
|
+
<div data-deadline>{new Date(d.endsAt).toLocaleDateString('zh-CN')} 过期</div>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
{d.code && <DiscountClaimButton discount={d}>领取</DiscountClaimButton>}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatDiscountValue(d: Discount): string {
|
|
229
|
+
if (d.value.__typename === 'DiscountPercentage') {
|
|
230
|
+
return `${100 - d.value.percentage} 折`;
|
|
231
|
+
}
|
|
232
|
+
if (d.value.__typename === 'DiscountAmount') {
|
|
233
|
+
return `减 ¥${d.value.amount.amount}`;
|
|
234
|
+
}
|
|
235
|
+
return '免运费';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ============================================================
|
|
239
|
+
// 5. DiscountClaimButton
|
|
240
|
+
// ============================================================
|
|
241
|
+
|
|
242
|
+
export interface DiscountClaimButtonProps {
|
|
243
|
+
discount: Discount;
|
|
244
|
+
children?: React.ReactNode;
|
|
245
|
+
loadingText?: React.ReactNode;
|
|
246
|
+
claimedText?: React.ReactNode;
|
|
247
|
+
onClaimed?: () => void;
|
|
248
|
+
onError?: (msg: string) => void;
|
|
249
|
+
className?: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function DiscountClaimButton(props: DiscountClaimButtonProps) {
|
|
253
|
+
const {
|
|
254
|
+
discount, children = '领取',
|
|
255
|
+
loadingText = '领取中…', claimedText = '已领取',
|
|
256
|
+
onClaimed, onError, className,
|
|
257
|
+
} = props;
|
|
258
|
+
const { claim, myDiscounts } = useDiscounts();
|
|
259
|
+
const analytics = useAnalytics();
|
|
260
|
+
const [loading, setLoading] = React.useState(false);
|
|
261
|
+
const [err, setErr] = React.useState<string | null>(null);
|
|
262
|
+
|
|
263
|
+
const alreadyClaimed = myDiscounts.some((c) => c.discount.id === discount.id);
|
|
264
|
+
|
|
265
|
+
const handleClaim = async () => {
|
|
266
|
+
if (!discount.code) return;
|
|
267
|
+
setLoading(true);
|
|
268
|
+
setErr(null);
|
|
269
|
+
try {
|
|
270
|
+
const r = await claim(discount.code);
|
|
271
|
+
if (r.userErrors.length > 0) {
|
|
272
|
+
const msg = r.userErrors[0].message;
|
|
273
|
+
setErr(msg);
|
|
274
|
+
onError?.(msg);
|
|
275
|
+
} else {
|
|
276
|
+
onClaimed?.();
|
|
277
|
+
analytics.emit('discount_claim', { code: discount.code });
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
setLoading(false);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
if (alreadyClaimed) {
|
|
285
|
+
return <button type="button" className={className} disabled data-discount-claim data-claimed>{claimedText}</button>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<>
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
className={className}
|
|
293
|
+
onClick={handleClaim}
|
|
294
|
+
disabled={loading}
|
|
295
|
+
data-discount-claim
|
|
296
|
+
data-loading={loading ? '' : undefined}
|
|
297
|
+
>
|
|
298
|
+
{loading ? loadingText : children}
|
|
299
|
+
</button>
|
|
300
|
+
{err && <div data-error>{err}</div>}
|
|
301
|
+
</>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ============================================================
|
|
306
|
+
// 6. MyDiscountList (我的卡包)
|
|
307
|
+
// ============================================================
|
|
308
|
+
|
|
309
|
+
export interface MyDiscountListProps {
|
|
310
|
+
className?: string;
|
|
311
|
+
emptyText?: React.ReactNode;
|
|
312
|
+
/** 过滤 tabs(available / used / expired),默认显示 available */
|
|
313
|
+
filter?: 'available' | 'used' | 'expired' | 'all';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function MyDiscountList(props: MyDiscountListProps) {
|
|
317
|
+
const { className, emptyText = '还没有优惠券', filter = 'available' } = props;
|
|
318
|
+
const { myDiscounts, myDiscountsStatus } = useDiscounts();
|
|
319
|
+
|
|
320
|
+
if (myDiscountsStatus === 'loading') return null;
|
|
321
|
+
if (myDiscountsStatus === 'unauthenticated') return null;
|
|
322
|
+
|
|
323
|
+
const filtered = myDiscounts.filter((c) => {
|
|
324
|
+
if (filter === 'all') return true;
|
|
325
|
+
if (filter === 'expired') return c.isExpired;
|
|
326
|
+
if (filter === 'used') return c.remainingUses === 0 && !c.isExpired;
|
|
327
|
+
// available
|
|
328
|
+
return !c.isExpired && c.remainingUses > 0;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (filtered.length === 0) return <div data-my-discount-empty>{emptyText}</div>;
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div data-my-discount-list className={className}>
|
|
335
|
+
{filtered.map((c) => (
|
|
336
|
+
<div
|
|
337
|
+
key={c.id}
|
|
338
|
+
data-my-discount-item
|
|
339
|
+
data-expired={c.isExpired ? '' : undefined}
|
|
340
|
+
data-used={c.remainingUses === 0 ? '' : undefined}
|
|
341
|
+
>
|
|
342
|
+
<div data-info>
|
|
343
|
+
<div data-title>{c.discount.title}</div>
|
|
344
|
+
<div data-value>{formatClaimValue(c)}</div>
|
|
345
|
+
{c.discount.minSubtotal && (
|
|
346
|
+
<div data-condition>
|
|
347
|
+
满 <Money data={{ amount: c.discount.minSubtotal.amount, currencyCode: c.discount.minSubtotal.currencyCode }} /> 可用
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
{c.expiresAt && (
|
|
351
|
+
<div data-deadline>{new Date(c.expiresAt).toLocaleDateString('zh-CN')} 过期</div>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
{c.discount.code && <code data-code>{c.discount.code}</code>}
|
|
355
|
+
</div>
|
|
356
|
+
))}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function formatClaimValue(c: DiscountClaim): string {
|
|
362
|
+
if (c.discount.valueType === 'PERCENTAGE' && c.discount.valuePercentage != null) {
|
|
363
|
+
return `${100 - c.discount.valuePercentage} 折`;
|
|
364
|
+
}
|
|
365
|
+
if (c.discount.valueType === 'FIXED_AMOUNT' && c.discount.valueAmount) {
|
|
366
|
+
return `减 ¥${c.discount.valueAmount.amount}`;
|
|
367
|
+
}
|
|
368
|
+
return '免运费';
|
|
369
|
+
}
|