@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,152 @@
1
+ /**
2
+ * <AddressPicker>
3
+ *
4
+ * Checkout 用的地址选择器。**自带**:
5
+ * - 从 useAddressBook() 拿 list
6
+ * - radio 渲染 + 默认选 defaultAddress
7
+ * - 选 "新地址" 时展开新增表单
8
+ *
9
+ * 用法(受控):
10
+ * <AddressPicker
11
+ * value={selectedAddressId}
12
+ * onChange={(id, addr) => setSelectedAddressId(id)}
13
+ * allowNewAddress
14
+ * onUseNewAddress={(addr) => setInlineAddress(addr)}
15
+ * />
16
+ *
17
+ * 用法(非受控):
18
+ * <AddressPicker onSelect={(addr) => ...} />
19
+ * 组件内部 state;选择后回调商家拿值。
20
+ */
21
+
22
+ import * as React from 'react';
23
+ import { useAddressBook, type Address } from './AddressBookProvider';
24
+ import { AddressForm } from './AddressForm';
25
+
26
+ export interface AddressPickerProps {
27
+ /** 受控:当前选中的 address ID */
28
+ value?: string | null;
29
+ /** 受控:选中变化(同时拿到 address 对象) */
30
+ onChange?: (addressId: string | null, address: Address | null) => void;
31
+ /** 非受控:选择时回调(不需要外面 state) */
32
+ onSelect?: (address: Address | null) => void;
33
+ /** 是否允许"使用新地址"选项 */
34
+ allowNewAddress?: boolean;
35
+ /** 选了"新地址"后填表保存的回调(拿到新建好的 Address) */
36
+ onUseNewAddress?: (address: Address) => void;
37
+ /** 空状态:没保存地址时显示什么。默认渲染内嵌 AddressForm */
38
+ emptyFallback?: React.ReactNode;
39
+ className?: string;
40
+ }
41
+
42
+ export function AddressPicker(props: AddressPickerProps) {
43
+ const {
44
+ value, onChange, onSelect,
45
+ allowNewAddress = true,
46
+ onUseNewAddress,
47
+ emptyFallback,
48
+ className,
49
+ } = props;
50
+ const { addresses, defaultAddress, status } = useAddressBook();
51
+
52
+ // 非受控 fallback
53
+ const [internalId, setInternalId] = React.useState<string | null>(null);
54
+ const selectedId = value !== undefined ? value : internalId;
55
+ const setSelectedId = React.useCallback((id: string | null, addr: Address | null) => {
56
+ if (value === undefined) setInternalId(id);
57
+ onChange?.(id, addr);
58
+ onSelect?.(addr);
59
+ }, [value, onChange, onSelect]);
60
+
61
+ const [showNewForm, setShowNewForm] = React.useState(false);
62
+
63
+ // 初次:自动选 default address
64
+ React.useEffect(() => {
65
+ if (selectedId == null && defaultAddress) {
66
+ setSelectedId(defaultAddress.id, defaultAddress);
67
+ } else if (selectedId == null && addresses.length > 0) {
68
+ setSelectedId(addresses[0].id, addresses[0]);
69
+ }
70
+ }, [defaultAddress, addresses, selectedId, setSelectedId]);
71
+
72
+ if (status === 'loading') {
73
+ return <div data-address-picker data-loading>加载地址中…</div>;
74
+ }
75
+
76
+ // 0 个地址:直接展示一个新增表单
77
+ if (addresses.length === 0) {
78
+ if (emptyFallback) return <>{emptyFallback}</>;
79
+ return (
80
+ <div data-address-picker data-empty className={className}>
81
+ <div data-picker-title>填写收货地址</div>
82
+ <AddressForm
83
+ onSave={(addr) => {
84
+ if (addr) {
85
+ setSelectedId(addr.id, addr);
86
+ onUseNewAddress?.(addr);
87
+ }
88
+ }}
89
+ />
90
+ </div>
91
+ );
92
+ }
93
+
94
+ return (
95
+ <div data-address-picker className={className}>
96
+ <div data-picker-title>选择收货地址</div>
97
+ <div data-picker-list>
98
+ {addresses.map((a) => (
99
+ <label key={a.id} data-picker-item data-active={selectedId === a.id ? '' : undefined}>
100
+ <input
101
+ type="radio"
102
+ name="address-picker"
103
+ checked={selectedId === a.id}
104
+ onChange={() => { setSelectedId(a.id, a); setShowNewForm(false); }}
105
+ />
106
+ <div>
107
+ <div data-name>
108
+ {a.firstName || ''}{a.lastName || ''}
109
+ {a.isDefault && <span data-default-tag>默认</span>}
110
+ </div>
111
+ <div data-line>
112
+ {a.phone} · {a.province}{a.city}{a.district || ''}{a.address1 || ''}
113
+ </div>
114
+ </div>
115
+ </label>
116
+ ))}
117
+ {allowNewAddress && (
118
+ <label data-picker-item data-active={showNewForm ? '' : undefined}>
119
+ <input
120
+ type="radio"
121
+ name="address-picker"
122
+ checked={showNewForm}
123
+ onChange={() => { setShowNewForm(true); setSelectedId(null, null); }}
124
+ />
125
+ <div>
126
+ <div data-name>+ 使用新地址</div>
127
+ <div data-line>填写一个新地址(默认保存到地址簿)</div>
128
+ </div>
129
+ </label>
130
+ )}
131
+ </div>
132
+ {showNewForm && allowNewAddress && (
133
+ <div data-new-address-form>
134
+ <AddressForm
135
+ onCancel={() => {
136
+ setShowNewForm(false);
137
+ const fallback = defaultAddress || addresses[0];
138
+ if (fallback) setSelectedId(fallback.id, fallback);
139
+ }}
140
+ onSave={(addr) => {
141
+ if (addr) {
142
+ setShowNewForm(false);
143
+ setSelectedId(addr.id, addr);
144
+ onUseNewAddress?.(addr);
145
+ }
146
+ }}
147
+ />
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
@@ -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
+ }