@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.
Files changed (36) 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/index.d.ts +12 -0
  26. package/dist/components/index.d.ts.map +1 -1
  27. package/dist/components/index.js +8 -0
  28. package/dist/components/index.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/components/AddressBookProvider.tsx +279 -0
  31. package/src/components/AddressForm.tsx +198 -0
  32. package/src/components/AddressList.tsx +110 -0
  33. package/src/components/AddressPicker.tsx +152 -0
  34. package/src/components/DiscountComponents.tsx +369 -0
  35. package/src/components/DiscountProvider.tsx +455 -0
  36. 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
+ }