@shopbb/helium 0.3.1 → 0.5.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 (45) 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/CartProvider.d.ts.map +1 -1
  18. package/dist/components/CartProvider.js +9 -0
  19. package/dist/components/CartProvider.js.map +1 -1
  20. package/dist/components/DiscountComponents.d.ts +49 -0
  21. package/dist/components/DiscountComponents.d.ts.map +1 -0
  22. package/dist/components/DiscountComponents.js +119 -0
  23. package/dist/components/DiscountComponents.js.map +1 -0
  24. package/dist/components/DiscountProvider.d.ts +136 -0
  25. package/dist/components/DiscountProvider.d.ts.map +1 -0
  26. package/dist/components/DiscountProvider.js +262 -0
  27. package/dist/components/DiscountProvider.js.map +1 -0
  28. package/dist/components/DiscountSelector.d.ts +36 -0
  29. package/dist/components/DiscountSelector.d.ts.map +1 -0
  30. package/dist/components/DiscountSelector.js +111 -0
  31. package/dist/components/DiscountSelector.js.map +1 -0
  32. package/dist/components/index.d.ts +14 -0
  33. package/dist/components/index.d.ts.map +1 -1
  34. package/dist/components/index.js +9 -0
  35. package/dist/components/index.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/AddressBookProvider.tsx +279 -0
  38. package/src/components/AddressForm.tsx +198 -0
  39. package/src/components/AddressList.tsx +110 -0
  40. package/src/components/AddressPicker.tsx +152 -0
  41. package/src/components/CartProvider.tsx +9 -0
  42. package/src/components/DiscountComponents.tsx +253 -0
  43. package/src/components/DiscountProvider.tsx +390 -0
  44. package/src/components/DiscountSelector.tsx +220 -0
  45. package/src/components/index.ts +61 -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
+ }
@@ -101,8 +101,17 @@ const CART_FRAGMENT = /* GraphQL */ `
101
101
  }
102
102
  cost {
103
103
  subtotalAmount { amount currencyCode }
104
+ totalDiscountAmount { amount currencyCode }
104
105
  totalAmount { amount currencyCode }
105
106
  }
107
+ discountCodes { code applicable }
108
+ discountAllocations {
109
+ discountedAmount { amount currencyCode }
110
+ targetType
111
+ targetId
112
+ title
113
+ code
114
+ }
106
115
  }
107
116
  `;
108
117
 
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Discount UI 组件集
3
+ *
4
+ * <AppliedDiscountList> 当前 cart 已应用的折扣分配(只读)
5
+ * <ClaimableDiscountList> 可领取的优惠券列表(首页 / 商品页)
6
+ * <DiscountClaimButton> 单个领取按钮
7
+ * <MyDiscountList> 我的卡包
8
+ *
9
+ * 注:W5 已删除以下组件(旧设计):
10
+ * - <DiscountCodeInput>: 输入框范式已过时。请用 <DiscountSelector> + <ClaimableDiscountList>。
11
+ * - <BestDiscountHint>: 服务端自动选择最佳券,不需要前端提示。用户在 <DiscountSelector> 看到 / 切换。
12
+ *
13
+ * 全部无样式 + data-* 钩子。
14
+ */
15
+
16
+ import * as React from 'react';
17
+ import { useDiscounts, useProductDiscounts, type Discount, type DiscountClaim } from './DiscountProvider';
18
+ import { useAnalytics } from './AnalyticsProvider';
19
+ import { Money } from './Money';
20
+
21
+ // ============================================================
22
+ // AppliedDiscountList
23
+ // ============================================================
24
+
25
+ export interface AppliedDiscountListProps {
26
+ className?: string;
27
+ emptyText?: React.ReactNode;
28
+ }
29
+
30
+ export function AppliedDiscountList(props: AppliedDiscountListProps) {
31
+ const { className, emptyText = null } = props;
32
+ const { cartAllocations } = useDiscounts();
33
+
34
+ if (cartAllocations.length === 0) return <>{emptyText}</>;
35
+
36
+ return (
37
+ <div data-applied-discount-list className={className}>
38
+ {cartAllocations.map((alloc, i) => (
39
+ <div key={alloc.code || i} data-applied-item>
40
+ <div data-info>
41
+ <span data-title>{alloc.title}</span>
42
+ {alloc.code && <span data-code>{alloc.code}</span>}
43
+ </div>
44
+ <span data-discount-amount>
45
+ − <Money data={{ amount: alloc.discountedAmount.amount, currencyCode: alloc.discountedAmount.currencyCode }} />
46
+ </span>
47
+ </div>
48
+ ))}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ // ============================================================
54
+ // ClaimableDiscountList
55
+ // ============================================================
56
+
57
+ export interface ClaimableDiscountListProps {
58
+ scope?: 'store' | 'product';
59
+ productHandle?: string;
60
+ first?: number;
61
+ className?: string;
62
+ emptyText?: React.ReactNode;
63
+ renderItem?: (discount: Discount) => React.ReactNode;
64
+ }
65
+
66
+ export function ClaimableDiscountList(props: ClaimableDiscountListProps) {
67
+ const { scope = 'store', productHandle, first = 10, className, emptyText = null, renderItem } = props;
68
+ const { publicDiscounts, publicDiscountsStatus } = useDiscounts();
69
+ const { discounts: productDiscs, loading: productLoading } = useProductDiscounts(scope === 'product' ? productHandle : null);
70
+
71
+ const list = scope === 'product' ? productDiscs : publicDiscounts;
72
+ const loading = scope === 'product' ? productLoading : publicDiscountsStatus === 'loading';
73
+
74
+ if (loading) return null;
75
+ if (list.length === 0) return <>{emptyText}</>;
76
+
77
+ return (
78
+ <div data-claimable-discount-list data-scope={scope} className={className}>
79
+ {list.slice(0, first).map((d) => (
80
+ <React.Fragment key={d.id}>
81
+ {renderItem ? renderItem(d) : <DefaultClaimableItem discount={d} />}
82
+ </React.Fragment>
83
+ ))}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ function DefaultClaimableItem({ discount: d }: { discount: Discount }) {
89
+ return (
90
+ <div data-claimable-item>
91
+ <div data-info>
92
+ <div data-title>{d.title}</div>
93
+ <div data-value>{formatDiscountValue(d)}</div>
94
+ {d.minSubtotal && (
95
+ <div data-condition>
96
+ 满 <Money data={{ amount: d.minSubtotal.amount, currencyCode: d.minSubtotal.currencyCode }} /> 可用
97
+ </div>
98
+ )}
99
+ {d.endsAt && (
100
+ <div data-deadline>{new Date(d.endsAt).toLocaleDateString('zh-CN')} 过期</div>
101
+ )}
102
+ </div>
103
+ {d.code && <DiscountClaimButton discount={d}>领取</DiscountClaimButton>}
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function formatDiscountValue(d: Discount): string {
109
+ if (d.value.__typename === 'DiscountPercentage') return `${100 - d.value.percentage} 折`;
110
+ if (d.value.__typename === 'DiscountAmount') return `减 ¥${d.value.amount.amount}`;
111
+ return '免运费';
112
+ }
113
+
114
+ // ============================================================
115
+ // DiscountClaimButton
116
+ // ============================================================
117
+
118
+ export interface DiscountClaimButtonProps {
119
+ discount: Discount;
120
+ children?: React.ReactNode;
121
+ loadingText?: React.ReactNode;
122
+ claimedText?: React.ReactNode;
123
+ /** 未登录时跳转的路径。默认 `/login?next=` + 当前路径 */
124
+ loginPath?: string;
125
+ onClaimed?: () => void;
126
+ onError?: (msg: string) => void;
127
+ className?: string;
128
+ }
129
+
130
+ export function DiscountClaimButton(props: DiscountClaimButtonProps) {
131
+ const {
132
+ discount, children = '领取',
133
+ loadingText = '领取中…', claimedText = '已领取',
134
+ loginPath,
135
+ onClaimed, onError, className,
136
+ } = props;
137
+ const { claim, myDiscounts, myDiscountsStatus } = useDiscounts();
138
+ const analytics = useAnalytics();
139
+ const [loading, setLoading] = React.useState(false);
140
+ const [err, setErr] = React.useState<string | null>(null);
141
+
142
+ const alreadyClaimed = myDiscounts.some((c) => c.discount.id === discount.id);
143
+
144
+ const handleClaim = async () => {
145
+ // 未登录 → 跳 login
146
+ if (myDiscountsStatus === 'unauthenticated') {
147
+ if (typeof window === 'undefined') return;
148
+ const next = encodeURIComponent(window.location.pathname + window.location.search);
149
+ window.location.href = loginPath || `/login?next=${next}`;
150
+ return;
151
+ }
152
+ if (!discount.code) return;
153
+ setLoading(true);
154
+ setErr(null);
155
+ try {
156
+ const r = await claim(discount.code);
157
+ if (r.userErrors.length > 0) {
158
+ const msg = r.userErrors[0].message;
159
+ setErr(msg);
160
+ onError?.(msg);
161
+ } else {
162
+ onClaimed?.();
163
+ analytics.emit('discount_claim', { code: discount.code });
164
+ }
165
+ } finally {
166
+ setLoading(false);
167
+ }
168
+ };
169
+
170
+ if (alreadyClaimed) {
171
+ return <button type="button" className={className} disabled data-discount-claim data-claimed>{claimedText}</button>;
172
+ }
173
+
174
+ return (
175
+ <>
176
+ <button
177
+ type="button"
178
+ className={className}
179
+ onClick={handleClaim}
180
+ disabled={loading}
181
+ data-discount-claim
182
+ data-loading={loading ? '' : undefined}
183
+ >
184
+ {loading ? loadingText : children}
185
+ </button>
186
+ {err && <div data-error>{err}</div>}
187
+ </>
188
+ );
189
+ }
190
+
191
+ // ============================================================
192
+ // MyDiscountList (我的卡包)
193
+ // ============================================================
194
+
195
+ export interface MyDiscountListProps {
196
+ className?: string;
197
+ emptyText?: React.ReactNode;
198
+ filter?: 'available' | 'used' | 'expired' | 'all';
199
+ }
200
+
201
+ export function MyDiscountList(props: MyDiscountListProps) {
202
+ const { className, emptyText = '还没有优惠券', filter = 'available' } = props;
203
+ const { myDiscounts, myDiscountsStatus } = useDiscounts();
204
+
205
+ if (myDiscountsStatus === 'loading') return null;
206
+ if (myDiscountsStatus === 'unauthenticated') return null;
207
+
208
+ const filtered = myDiscounts.filter((c) => {
209
+ if (filter === 'all') return true;
210
+ if (filter === 'expired') return c.isExpired;
211
+ if (filter === 'used') return c.remainingUses === 0 && !c.isExpired;
212
+ return !c.isExpired && c.remainingUses > 0;
213
+ });
214
+
215
+ if (filtered.length === 0) return <div data-my-discount-empty>{emptyText}</div>;
216
+
217
+ return (
218
+ <div data-my-discount-list className={className}>
219
+ {filtered.map((c) => (
220
+ <div
221
+ key={c.id}
222
+ data-my-discount-item
223
+ data-expired={c.isExpired ? '' : undefined}
224
+ data-used={c.remainingUses === 0 ? '' : undefined}
225
+ >
226
+ <div data-info>
227
+ <div data-title>{c.discount.title}</div>
228
+ <div data-value>{formatClaimValue(c)}</div>
229
+ {c.discount.minSubtotal && (
230
+ <div data-condition>
231
+ 满 <Money data={{ amount: c.discount.minSubtotal.amount, currencyCode: c.discount.minSubtotal.currencyCode }} /> 可用
232
+ </div>
233
+ )}
234
+ {c.expiresAt && (
235
+ <div data-deadline>{new Date(c.expiresAt).toLocaleDateString('zh-CN')} 过期</div>
236
+ )}
237
+ </div>
238
+ {c.discount.code && <code data-code>{c.discount.code}</code>}
239
+ </div>
240
+ ))}
241
+ </div>
242
+ );
243
+ }
244
+
245
+ function formatClaimValue(c: DiscountClaim): string {
246
+ if (c.discount.valueType === 'PERCENTAGE' && c.discount.valuePercentage != null) {
247
+ return `${100 - c.discount.valuePercentage} 折`;
248
+ }
249
+ if (c.discount.valueType === 'FIXED_AMOUNT' && c.discount.valueAmount) {
250
+ return `减 ¥${c.discount.valueAmount.amount}`;
251
+ }
252
+ return '免运费';
253
+ }