@shopbb/helium 0.4.0 → 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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * <DiscountSelector>
3
+ *
4
+ * Cart 上的优惠券选择器 — 卡片式 UI,对齐拼多多 / 京东体验。
5
+ *
6
+ * 行为:
7
+ * - 默认显示当前应用的券(服务端自动选了最佳,或买家显式选的)
8
+ * - 点"切换"打开抽屉/弹窗,列出该买家所有可用券(从 useDiscounts().myDiscounts)
9
+ * - 选另一张 → 调 cartDiscountSelect mutation
10
+ * - 点"不使用优惠券" → cartDiscountClear
11
+ *
12
+ * 未登录买家:渲染"登录后享受优惠"提示。
13
+ *
14
+ * 设计目标:
15
+ * - 这是 helium "脚手架" 的卡片式实现。商家可以替换为抽屉式 / 弹窗式自定义 UI。
16
+ * - 商家也可以直接用 useDiscounts() 自己渲染(提供 hook-only 路径)。
17
+ */
18
+
19
+ import * as React from 'react';
20
+ import { useDiscounts, type DiscountClaim } from './DiscountProvider';
21
+ import { useCart } from './CartProvider';
22
+ import { useShop } from './ShopProvider';
23
+ import { useAnalytics } from './AnalyticsProvider';
24
+ import { Money } from './Money';
25
+
26
+ export interface DiscountSelectorProps {
27
+ className?: string;
28
+ /** 未登录时显示的内容;不传则显示默认 */
29
+ unauthenticatedFallback?: React.ReactNode;
30
+ /** 自定义渲染 */
31
+ renderApplied?: (props: { code: string | null; title: string; amount: { amount: string; currencyCode: string }; onChange: () => void; onClear: () => void }) => React.ReactNode;
32
+ }
33
+
34
+ export function DiscountSelector(props: DiscountSelectorProps) {
35
+ const { className, unauthenticatedFallback, renderApplied } = props;
36
+ const { myDiscounts, myDiscountsStatus, cartAllocations } = useDiscounts();
37
+ const { cart, refetch: refetchCart } = useCart();
38
+ const shop = useShop();
39
+ const analytics = useAnalytics();
40
+
41
+ const [open, setOpen] = React.useState(false);
42
+ const [pending, setPending] = React.useState(false);
43
+
44
+ // 服务端已经决定了 cart 上是哪张券
45
+ const appliedAlloc = cartAllocations[0];
46
+ const appliedClaimId = (cart as any)?.appliedDiscountClaim?.claimId ?? null;
47
+ const appliedTitle = (cart as any)?.appliedDiscountClaim?.title ?? appliedAlloc?.title;
48
+ const appliedCode = (cart as any)?.appliedDiscountClaim?.code ?? appliedAlloc?.code;
49
+
50
+ const callSelectMutation = async (claimId: string | null) => {
51
+ if (!cart) return;
52
+ setPending(true);
53
+ try {
54
+ const token = (typeof localStorage !== 'undefined') ? (localStorage.getItem('shopbb:buyer_token') || localStorage.getItem('shopflare:buyer_token')) : null;
55
+ const mutation = claimId
56
+ ? `mutation S($id: ID!, $c: ID!) { cartDiscountSelect(cartId: $id, claimId: $c) { cart { id } userErrors { message } } }`
57
+ : `mutation C($id: ID!) { cartDiscountClear(cartId: $id) { cart { id } userErrors { message } } }`;
58
+ const variables: any = { id: cart.id };
59
+ if (claimId) variables.c = claimId;
60
+ const res = await fetch(shop.apiUrl, {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Content-Type': 'application/json',
64
+ 'X-Storefront-Access-Token': shop.storefrontAccessToken,
65
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
66
+ },
67
+ body: JSON.stringify({ query: mutation, variables }),
68
+ credentials: 'include',
69
+ });
70
+ await res.json();
71
+ // 重新拉 cart 让 UI 更新(拿到新的 discountAllocations)
72
+ await refetchCart();
73
+ } finally {
74
+ setPending(false);
75
+ setOpen(false);
76
+ }
77
+ };
78
+
79
+ // 未登录
80
+ if (myDiscountsStatus === 'unauthenticated') {
81
+ return unauthenticatedFallback !== undefined ? <>{unauthenticatedFallback}</> : (
82
+ <div data-discount-selector data-unauth className={className}>
83
+ <span>登录后可使用优惠券</span>
84
+ <a href="/login?next=/cart">去登录</a>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ if (myDiscountsStatus === 'loading') {
90
+ return <div data-discount-selector data-loading className={className}>加载优惠券…</div>;
91
+ }
92
+
93
+ const availableClaims = myDiscounts.filter((c) => !c.isExpired && c.remainingUses > 0);
94
+
95
+ if (availableClaims.length === 0) {
96
+ return (
97
+ <div data-discount-selector data-no-discount className={className}>
98
+ <span>暂无可用优惠券</span>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <>
105
+ <div data-discount-selector className={className}>
106
+ {appliedClaimId ? (
107
+ renderApplied ? renderApplied({
108
+ code: appliedCode ?? null,
109
+ title: appliedTitle ?? '',
110
+ amount: appliedAlloc?.discountedAmount ?? { amount: '0', currencyCode: 'CNY' },
111
+ onChange: () => setOpen(true),
112
+ onClear: () => callSelectMutation(null),
113
+ }) : (
114
+ <div data-applied>
115
+ <div data-info>
116
+ <span data-label>已使用</span>
117
+ <strong data-title>{appliedTitle}</strong>
118
+ {appliedAlloc && (
119
+ <span data-amount>− <Money data={{ amount: appliedAlloc.discountedAmount.amount, currencyCode: appliedAlloc.discountedAmount.currencyCode }} /></span>
120
+ )}
121
+ </div>
122
+ <div data-actions>
123
+ <button type="button" onClick={() => setOpen(true)} disabled={pending}>切换</button>
124
+ <button type="button" onClick={() => callSelectMutation(null)} disabled={pending} data-clear>不使用</button>
125
+ </div>
126
+ </div>
127
+ )
128
+ ) : (
129
+ <div data-empty-applied>
130
+ <div data-info>
131
+ <span data-label>有 {availableClaims.length} 张可用优惠券</span>
132
+ </div>
133
+ <button type="button" onClick={() => setOpen(true)} disabled={pending}>选择</button>
134
+ </div>
135
+ )}
136
+ </div>
137
+
138
+ {open && (
139
+ <DiscountPickerSheet
140
+ claims={availableClaims}
141
+ currentClaimId={appliedClaimId}
142
+ onSelect={(claimId) => {
143
+ analytics.emit('discount_select', { claimId });
144
+ callSelectMutation(claimId);
145
+ }}
146
+ onClose={() => setOpen(false)}
147
+ pending={pending}
148
+ />
149
+ )}
150
+ </>
151
+ );
152
+ }
153
+
154
+ /**
155
+ * 抽屉式选券面板。
156
+ * Demo 实现为屏底固定面板;商家可以替换为 modal / 抽屉等。
157
+ */
158
+ function DiscountPickerSheet({
159
+ claims, currentClaimId, onSelect, onClose, pending,
160
+ }: {
161
+ claims: DiscountClaim[];
162
+ currentClaimId: string | null;
163
+ onSelect: (claimId: string) => void;
164
+ onClose: () => void;
165
+ pending: boolean;
166
+ }) {
167
+ return (
168
+ <div data-discount-picker-overlay onClick={onClose}>
169
+ <div data-discount-picker-sheet onClick={(e) => e.stopPropagation()}>
170
+ <div data-sheet-header>
171
+ <h3>选择优惠券</h3>
172
+ <button type="button" onClick={onClose} data-sheet-close>×</button>
173
+ </div>
174
+ <div data-sheet-list>
175
+ {claims.map((c) => {
176
+ const id = c.id.replace(/^gid:\/\/shopbb\/DiscountClaim\//, '');
177
+ const isCurrent = currentClaimId === id;
178
+ return (
179
+ <button
180
+ key={c.id}
181
+ type="button"
182
+ data-claim-card
183
+ data-current={isCurrent ? '' : undefined}
184
+ onClick={() => onSelect(id)}
185
+ disabled={pending}
186
+ >
187
+ <div data-claim-left>
188
+ <div data-claim-value>{formatValue(c)}</div>
189
+ {c.discount.minSubtotal && (
190
+ <div data-claim-min>
191
+ 满 <Money data={{ amount: c.discount.minSubtotal.amount, currencyCode: c.discount.minSubtotal.currencyCode }} /> 可用
192
+ </div>
193
+ )}
194
+ </div>
195
+ <div data-claim-right>
196
+ <div data-claim-title>{c.discount.title}</div>
197
+ {c.discount.code && <div data-claim-code>{c.discount.code}</div>}
198
+ {c.expiresAt && (
199
+ <div data-claim-expiry>{new Date(c.expiresAt).toLocaleDateString('zh-CN')} 过期</div>
200
+ )}
201
+ </div>
202
+ {isCurrent && <span data-claim-current-tag>当前使用</span>}
203
+ </button>
204
+ );
205
+ })}
206
+ </div>
207
+ </div>
208
+ </div>
209
+ );
210
+ }
211
+
212
+ function formatValue(c: DiscountClaim): string {
213
+ if (c.discount.valueType === 'PERCENTAGE' && c.discount.valuePercentage != null) {
214
+ return `${100 - c.discount.valuePercentage} 折`;
215
+ }
216
+ if (c.discount.valueType === 'FIXED_AMOUNT' && c.discount.valueAmount) {
217
+ return `− ¥${c.discount.valueAmount.amount}`;
218
+ }
219
+ return '免运费';
220
+ }
@@ -116,18 +116,17 @@ export type {
116
116
  } from './DiscountProvider';
117
117
 
118
118
  export {
119
- DiscountCodeInput,
120
119
  AppliedDiscountList,
121
- BestDiscountHint,
122
120
  ClaimableDiscountList,
123
121
  DiscountClaimButton,
124
122
  MyDiscountList,
125
123
  } from './DiscountComponents';
126
124
  export type {
127
- DiscountCodeInputProps,
128
125
  AppliedDiscountListProps,
129
- BestDiscountHintProps,
130
126
  ClaimableDiscountListProps,
131
127
  DiscountClaimButtonProps,
132
128
  MyDiscountListProps,
133
129
  } from './DiscountComponents';
130
+
131
+ export { DiscountSelector } from './DiscountSelector';
132
+ export type { DiscountSelectorProps } from './DiscountSelector';