@tbox.cn/app-module-member 0.2.0 → 0.8.7
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/AGENTS.md +16 -0
- package/README.md +99 -11
- package/dist/chunk-EPQEF43P.js +1581 -0
- package/dist/chunk-QWGRWD5C.js +766 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +12 -2
- package/dist/member-372B74DY.css +1740 -0
- package/dist/server/index.d.ts +657 -286
- package/dist/server/index.js +11 -1
- package/package.json +25 -16
- package/skills/member/SKILL.md +141 -0
- package/skills/member/references/enrollment.md +34 -0
- package/skills/member/references/points-and-card.md +24 -0
- package/src/client/cards/index.ts +10 -0
- package/src/client/cards/member-card-summary/index.tsx +375 -0
- package/src/client/cards/member-enrollment-cta/index.tsx +61 -0
- package/src/client/cards/member-enrollment-form/index.tsx +181 -0
- package/src/client/cards/member-enrollment-status/index.tsx +65 -0
- package/src/client/cards/member-info/index.tsx +60 -0
- package/src/client/cards/member-login-cta/index.tsx +81 -0
- package/src/client/cards/member-point-usage-list/index.tsx +53 -0
- package/src/client/cards/view.ts +39 -0
- package/src/client/components/ImageWithFallback.tsx +38 -0
- package/src/client/index.ts +6 -4
- package/src/client/styles/member.css +1740 -0
- package/src/client/types/css.d.ts +2 -0
- package/src/server/action.ts +348 -0
- package/src/server/cards/index.ts +7 -7
- package/src/server/cards/member-card-summary/meta.ts +26 -5
- package/src/server/cards/member-card-summary/samples.ts +272 -0
- package/src/server/cards/member-enrollment-cta/meta.ts +19 -0
- package/src/server/cards/member-enrollment-form/meta.ts +10 -6
- package/src/server/cards/member-enrollment-status/meta.ts +5 -6
- package/src/server/cards/member-info/meta.ts +1 -1
- package/src/server/cards/member-login-cta/meta.ts +16 -0
- package/src/server/cards/member-point-usage-list/meta.ts +2 -3
- package/src/server/handler.ts +159 -119
- package/src/server/index.ts +129 -50
- package/src/server/ports.ts +66 -0
- package/src/server/service.ts +94 -80
- package/src/server/status.ts +87 -0
- package/src/server/subject-binding.ts +19 -0
- package/src/server/tool.ts +469 -99
- package/tbox.module.json +124 -0
- package/tests/cards-render.test.tsx +526 -0
- package/tests/enrollment.test.ts +121 -0
- package/tests/ports-resolve.test.ts +95 -0
- package/tests/preference-memory.test.ts +86 -0
- package/tests/samples.test.ts +61 -0
- package/tests/service.test.ts +593 -35
- package/tests/skills.test.ts +65 -0
- package/tests/snapshot-service.test.ts +75 -0
- package/tests/status.test.ts +127 -0
- package/tests/view.test.ts +33 -0
- package/tsup.config.ts +9 -1
- package/dist/chunk-JGZGLFO6.js +0 -111
- package/dist/chunk-ZV6B36AO.js +0 -613
- package/src/client/cards/index.tsx +0 -135
- package/src/server/cards/member-coupon-detail/meta.ts +0 -19
- package/src/server/cards/member-coupon-list/meta.ts +0 -18
- package/tbox.component.json +0 -28
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* member-card-summary 富卡:原型同款真实卡面 + 积分摘要。
|
|
3
|
+
* 会员卡名是 C 端卡名,不把 levelName 渲染成会员等级或权益。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import type { ReactNode, SyntheticEvent, UIEvent } from 'react';
|
|
7
|
+
import { createPortal } from 'react-dom';
|
|
8
|
+
import { X } from '@phosphor-icons/react';
|
|
9
|
+
import { markSelfRendering, sendCardAction } from '@tbox.cn/app-sdk/client';
|
|
10
|
+
import { Car, Confetti, Coins, Gift, Sparkle } from '@phosphor-icons/react';
|
|
11
|
+
import { ImageWithFallback } from '../../components/ImageWithFallback';
|
|
12
|
+
import { MEMBER_PREFERENCE_PROMPTS } from '@tbox.cn/app-contracts-mall';
|
|
13
|
+
import type { MemberCardSummaryCardData } from '@tbox.cn/app-contracts-mall';
|
|
14
|
+
|
|
15
|
+
// 仅校验支付宝 scheme 前缀 + 非空 authority,**不依赖 webview `new URL`**——
|
|
16
|
+
// 安卓/UC 内核对 `alipays://` 非特殊 scheme 可能抛 TypeError 或解出非标字段,会让权益按钮(如积分换礼)
|
|
17
|
+
// 在安卓上落成不可点的静态态。深链合法性由 resolveAlipayNavigation 进一步校验。
|
|
18
|
+
function isProviderMiniappUrl(value: string): boolean {
|
|
19
|
+
return /^(alipays|alipayrisk):\/\/[^/?#]/u.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderBenefitText(text: string): ReactNode {
|
|
23
|
+
const parts = text.split(/(\d{3,4}-\d{7,8}|\d{7,12})/u);
|
|
24
|
+
return parts.map((part, index) => /^\d{3,4}-\d{7,8}$|^\d{7,12}$/u.test(part)
|
|
25
|
+
? <a key={`${part}-${String(index)}`} href={`tel:${part.replace(/-/gu, '')}`}>{part}</a>
|
|
26
|
+
: <span key={`${String(index)}-${part}`}>{part}</span>);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function BenefitFallbackIcon({ name }: { name: string }) {
|
|
30
|
+
const props = {
|
|
31
|
+
'aria-hidden': true,
|
|
32
|
+
className: 'member-card-summary__benefit-icon-fallback',
|
|
33
|
+
size: 28,
|
|
34
|
+
weight: 'duotone' as const,
|
|
35
|
+
};
|
|
36
|
+
if (/停车|泊车/.test(name)) return <Car {...props} />;
|
|
37
|
+
if (/积分|消费/.test(name)) return <Coins {...props} />;
|
|
38
|
+
if (/活动|派对/.test(name)) return <Confetti {...props} />;
|
|
39
|
+
return <Gift {...props} />;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 一屏权益格数:四列 × 两行。超出的排到下一屏,横向翻页。 */
|
|
43
|
+
const BENEFITS_PER_PAGE = 8;
|
|
44
|
+
|
|
45
|
+
export function MemberCardSummaryCard(props: {
|
|
46
|
+
data: MemberCardSummaryCardData;
|
|
47
|
+
cardId?: string;
|
|
48
|
+
isHistory?: boolean;
|
|
49
|
+
}) {
|
|
50
|
+
const { data, cardId = '', isHistory = false } = props;
|
|
51
|
+
const dynamicFace = data.presentation !== undefined;
|
|
52
|
+
const labelsEmbedded = data.presentation?.labelsEmbedded === true;
|
|
53
|
+
// 不截断:权益数量本身随等级变化(完整卡面 provider 的采集快照里,低两档 14 项、
|
|
54
|
+
// 高两档 15 项——高档多出的正是免费停车),截到固定条数会把「等级差异」这个卡面最该
|
|
55
|
+
// 体现的东西抹平。条数上限由契约的 benefits.max(20) 兜住。
|
|
56
|
+
// 但十来项铺成四行会把卡片撑得很长,故按「四列 × 两行」切页横向翻——一屏之内看完八项,
|
|
57
|
+
// 其余滑过去,卡片高度不随权益数变化。
|
|
58
|
+
const benefitPages = useMemo(() => {
|
|
59
|
+
const items = data.benefits;
|
|
60
|
+
if (!items?.length) return [];
|
|
61
|
+
const pages: (typeof items)[] = [];
|
|
62
|
+
for (let i = 0; i < items.length; i += BENEFITS_PER_PAGE) {
|
|
63
|
+
pages.push(items.slice(i, i + BENEFITS_PER_PAGE));
|
|
64
|
+
}
|
|
65
|
+
return pages;
|
|
66
|
+
}, [data.benefits]);
|
|
67
|
+
const [benefitPage, setBenefitPage] = useState(0);
|
|
68
|
+
// 翻页指示只跟随滚动,不反向驱动滚动——省一个 scrollTo 的受控回路,
|
|
69
|
+
// 用户用手指滑或用触控板都只有这一条数据流。
|
|
70
|
+
const onBenefitsScroll = (event: UIEvent<HTMLDivElement>) => {
|
|
71
|
+
const el = event.currentTarget;
|
|
72
|
+
if (el.clientWidth === 0) return;
|
|
73
|
+
const next = Math.round(el.scrollLeft / el.clientWidth);
|
|
74
|
+
setBenefitPage((prev) => (prev === next ? prev : next));
|
|
75
|
+
};
|
|
76
|
+
const preferencePrompts = data.preferencePrompts ?? MEMBER_PREFERENCE_PROMPTS;
|
|
77
|
+
const [pointsRuleOpen, setPointsRuleOpen] = useState(false);
|
|
78
|
+
const [pointsRuleClosing, setPointsRuleClosing] = useState(false);
|
|
79
|
+
const [benefitAction, setBenefitAction] = useState<{ name: string; url: string; title?: string; text?: string; closing?: boolean }>();
|
|
80
|
+
const benefitActionCloseTimerRef = useRef<number>();
|
|
81
|
+
const pointsRuleTriggerRef = useRef<HTMLButtonElement>(null);
|
|
82
|
+
const pointsRuleCloseRef = useRef<HTMLButtonElement>(null);
|
|
83
|
+
const pointsRuleCloseTimerRef = useRef<number>();
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!pointsRuleOpen) return;
|
|
87
|
+
const previousOverflow = document.body.style.overflow;
|
|
88
|
+
document.body.style.overflow = 'hidden';
|
|
89
|
+
pointsRuleCloseRef.current?.focus();
|
|
90
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
91
|
+
if (event.key !== 'Escape') return;
|
|
92
|
+
closePointsRule();
|
|
93
|
+
};
|
|
94
|
+
window.addEventListener('keydown', onKeyDown);
|
|
95
|
+
return () => {
|
|
96
|
+
document.body.style.overflow = previousOverflow;
|
|
97
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
98
|
+
};
|
|
99
|
+
}, [pointsRuleOpen]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => () => {
|
|
102
|
+
window.clearTimeout(pointsRuleCloseTimerRef.current);
|
|
103
|
+
window.clearTimeout(benefitActionCloseTimerRef.current);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const closeBenefitAction = () => {
|
|
107
|
+
if (!benefitAction || benefitAction.closing) return;
|
|
108
|
+
setBenefitAction({ ...benefitAction, closing: true });
|
|
109
|
+
benefitActionCloseTimerRef.current = window.setTimeout(() => setBenefitAction(undefined), 180);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const finishClosePointsRule = () => {
|
|
113
|
+
window.clearTimeout(pointsRuleCloseTimerRef.current);
|
|
114
|
+
setPointsRuleOpen(false);
|
|
115
|
+
setPointsRuleClosing(false);
|
|
116
|
+
pointsRuleTriggerRef.current?.focus();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const closePointsRule = () => {
|
|
120
|
+
if (pointsRuleClosing) return;
|
|
121
|
+
setPointsRuleClosing(true);
|
|
122
|
+
pointsRuleCloseTimerRef.current = window.setTimeout(finishClosePointsRule, 220);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const openPointsRule = (event: SyntheticEvent<HTMLButtonElement>) => {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
event.stopPropagation();
|
|
128
|
+
setPointsRuleClosing(false);
|
|
129
|
+
setPointsRuleOpen(true);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const pointsRuleTrigger = data.pointsRuleImageUrl || data.pointsRuleContent ? (
|
|
133
|
+
<button
|
|
134
|
+
ref={pointsRuleTriggerRef}
|
|
135
|
+
type="button"
|
|
136
|
+
className={labelsEmbedded
|
|
137
|
+
? 'member-card-summary__rule-hotspot'
|
|
138
|
+
: 'member-card-summary__rule-trigger'}
|
|
139
|
+
aria-label="积分规则"
|
|
140
|
+
aria-haspopup="dialog"
|
|
141
|
+
onClick={openPointsRule}
|
|
142
|
+
>
|
|
143
|
+
{labelsEmbedded ? null : <span>积分规则</span>}
|
|
144
|
+
</button>
|
|
145
|
+
) : null;
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<section className={`member-card member-card-summary${dynamicFace ? ` member-card-summary--dynamic member-card-summary--${data.presentation!.variant}` : ''}`}>
|
|
149
|
+
{dynamicFace ? (
|
|
150
|
+
<div
|
|
151
|
+
className={`member-card-summary__face member-card-summary__face--dynamic member-card-summary__face--${data.presentation!.variant}${labelsEmbedded ? ' member-card-summary__face--embedded' : ''}`}
|
|
152
|
+
aria-label={`${data.cardDisplay},可用积分 ${String(data.availablePoints)}`}
|
|
153
|
+
>
|
|
154
|
+
{data.cardImageUrl ? (
|
|
155
|
+
<ImageWithFallback
|
|
156
|
+
src={data.cardImageUrl}
|
|
157
|
+
alt=""
|
|
158
|
+
className="member-card-summary__background"
|
|
159
|
+
size={96}
|
|
160
|
+
loading="eager"
|
|
161
|
+
/>
|
|
162
|
+
) : (
|
|
163
|
+
<span className="member-card-summary__background member-card-summary__background--fallback" />
|
|
164
|
+
)}
|
|
165
|
+
<div className="member-card-summary__overlay">
|
|
166
|
+
{!labelsEmbedded && (
|
|
167
|
+
<>
|
|
168
|
+
<strong className="member-card-summary__level">{data.cardDisplay}</strong>
|
|
169
|
+
{data.pointsUrl ? <button type="button" className="member-card-summary__points-label" onClick={() => sendCardAction(cardId, { type: 'openLink', url: data.pointsUrl! })}>我的积分 <span aria-hidden="true">></span></button> : <span className="member-card-summary__points-label">我的积分 <span aria-hidden="true">></span></span>}
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
<b className={`member-card-summary__balance${labelsEmbedded ? ' member-card-summary__balance--embedded' : ''}`}>{String(data.availablePoints)}</b>
|
|
173
|
+
{data.progress && (
|
|
174
|
+
<div className="member-card-summary__progress">
|
|
175
|
+
<div className={`member-card-summary__progress-text${data.progress.message.length > 24 ? ' member-card-summary__progress-text--scroll' : ''}`}>
|
|
176
|
+
<span>{data.progress.message}</span>
|
|
177
|
+
</div>
|
|
178
|
+
<div
|
|
179
|
+
className="member-card-summary__progress-track"
|
|
180
|
+
role="progressbar"
|
|
181
|
+
aria-label="会员等级消费进度"
|
|
182
|
+
aria-valuemin={0}
|
|
183
|
+
aria-valuemax={100}
|
|
184
|
+
aria-valuenow={Math.round(data.progress.percent)}
|
|
185
|
+
>
|
|
186
|
+
<span style={{ width: `${data.progress.percent}%` }} />
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
{labelsEmbedded && data.pointsUrl && (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
className="member-card-summary__points-hotspot"
|
|
194
|
+
aria-label="打开我的积分"
|
|
195
|
+
onClick={() => sendCardAction(cardId, { type: 'openLink', url: data.pointsUrl! })}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
198
|
+
{pointsRuleTrigger}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
) : data.cardImageUrl ? (
|
|
202
|
+
<ImageWithFallback
|
|
203
|
+
src={data.cardImageUrl}
|
|
204
|
+
alt={data.cardDisplay}
|
|
205
|
+
className="member-card-summary__face"
|
|
206
|
+
size={96}
|
|
207
|
+
loading="eager"
|
|
208
|
+
/>
|
|
209
|
+
) : (
|
|
210
|
+
<div className="member-card-summary__face member-card-summary__face--gradient">
|
|
211
|
+
<strong>{data.cardDisplay}</strong>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
{!dynamicFace && (
|
|
215
|
+
<div className="member-card-summary__metric">
|
|
216
|
+
<b className="member-card-summary__points-value">{String(data.availablePoints)}</b>
|
|
217
|
+
<span className="member-card-summary__metric-label">积分</span>
|
|
218
|
+
{data.status !== 'active' && (
|
|
219
|
+
<em className="member-card-summary__status">{data.status}</em>
|
|
220
|
+
)}
|
|
221
|
+
{pointsRuleTrigger}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
{benefitPages.length > 0 && (
|
|
225
|
+
<>
|
|
226
|
+
{/* 无区块标题:这张卡本身就是会员卡,图标加名称已经自解释,多一行「可享权益」
|
|
227
|
+
只是把卡片拉长。语义靠 pager 的 aria-label 交代,读屏不受影响。 */}
|
|
228
|
+
<div
|
|
229
|
+
className="member-card-summary__benefit-pager"
|
|
230
|
+
role="group"
|
|
231
|
+
aria-label="当前会员权益"
|
|
232
|
+
onScroll={benefitPages.length > 1 ? onBenefitsScroll : undefined}
|
|
233
|
+
>
|
|
234
|
+
{benefitPages.map((page) => (
|
|
235
|
+
<ul className="member-card-summary__benefits" key={page[0].benefitRef}>
|
|
236
|
+
{page.map((benefit) => (
|
|
237
|
+
<li key={benefit.benefitRef}>
|
|
238
|
+
{benefit.action?.kind === 'miniapp' && benefit.action.url && isProviderMiniappUrl(benefit.action.url) ? (
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
className="member-card-summary__benefit-button"
|
|
242
|
+
aria-label={`打开${benefit.name}`}
|
|
243
|
+
onClick={() => sendCardAction(cardId, { type: 'openLink', url: benefit.action!.url! })}
|
|
244
|
+
>
|
|
245
|
+
<span className="member-card-summary__benefit-icon">
|
|
246
|
+
<ImageWithFallback
|
|
247
|
+
src={benefit.iconUrl}
|
|
248
|
+
alt={`${benefit.name}权益图标`}
|
|
249
|
+
size={48}
|
|
250
|
+
// 与卡面底图同批加载:ImageWithFallback 默认 lazy,而权益图标挂在卡片
|
|
251
|
+
// 下半部,实测滚进视口后仍长时间不触发请求,整排图标空着。它们是卡片主体
|
|
252
|
+
// 的一部分(十来张 14KB 小图),没有延后的理由。
|
|
253
|
+
loading="eager"
|
|
254
|
+
fallback={<BenefitFallbackIcon name={benefit.name} />}
|
|
255
|
+
/>
|
|
256
|
+
</span>
|
|
257
|
+
<span className="member-card-summary__benefit-copy">
|
|
258
|
+
<span>{benefit.name}</span>
|
|
259
|
+
{benefit.description && (
|
|
260
|
+
<small className="member-card-summary__benefit-description">{benefit.description}</small>
|
|
261
|
+
)}
|
|
262
|
+
</span>
|
|
263
|
+
</button>
|
|
264
|
+
) : benefit.action?.kind === 'toast' ? (
|
|
265
|
+
<button type="button" className="member-card-summary__benefit-button" onClick={() => sendCardAction(cardId, { type: 'toast', content: benefit.action?.text ?? '敬请期待~' })}>
|
|
266
|
+
<span className="member-card-summary__benefit-icon"><ImageWithFallback src={benefit.iconUrl} alt={`${benefit.name}权益图标`} size={48} loading="eager" fallback={<BenefitFallbackIcon name={benefit.name} />} /></span>
|
|
267
|
+
<span className="member-card-summary__benefit-copy"><span>{benefit.name}</span></span>
|
|
268
|
+
</button>
|
|
269
|
+
) : benefit.action?.kind === 'dialog' ? (
|
|
270
|
+
<button type="button" className="member-card-summary__benefit-button" onClick={() => setBenefitAction({ name: benefit.action?.title ?? benefit.name, url: '', title: benefit.action?.title, text: benefit.action?.text })}>
|
|
271
|
+
<span className="member-card-summary__benefit-icon"><ImageWithFallback src={benefit.iconUrl} alt={`${benefit.name}权益图标`} size={48} loading="eager" fallback={<BenefitFallbackIcon name={benefit.name} />} /></span>
|
|
272
|
+
<span className="member-card-summary__benefit-copy"><span>{benefit.name}</span></span>
|
|
273
|
+
</button>
|
|
274
|
+
) : (
|
|
275
|
+
<>
|
|
276
|
+
<span className="member-card-summary__benefit-icon">
|
|
277
|
+
<ImageWithFallback src={benefit.iconUrl} alt={`${benefit.name}权益图标`} size={48} loading="eager" fallback={<BenefitFallbackIcon name={benefit.name} />} />
|
|
278
|
+
</span>
|
|
279
|
+
<span className="member-card-summary__benefit-copy"><span>{benefit.name}</span>{benefit.description && <small className="member-card-summary__benefit-description">{benefit.description}</small>}</span>
|
|
280
|
+
</>
|
|
281
|
+
)}
|
|
282
|
+
</li>
|
|
283
|
+
))}
|
|
284
|
+
</ul>
|
|
285
|
+
))}
|
|
286
|
+
</div>
|
|
287
|
+
{benefitPages.length > 1 && (
|
|
288
|
+
<div className="member-card-summary__benefit-dots" aria-hidden="true">
|
|
289
|
+
{benefitPages.map((page, index) => (
|
|
290
|
+
<span key={page[0].benefitRef} data-active={index === benefitPage} />
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</>
|
|
295
|
+
)}
|
|
296
|
+
{/*
|
|
297
|
+
* 这三个按钮不是「换个话题」的快捷入口,而是偏好采集口:点击经 handler 的 intents
|
|
298
|
+
* 精确截获、确定性写入 kind=preference 记忆,再同轮续接导购(见 Agent Note
|
|
299
|
+
* 会员卡偏好三问与确定性记忆)。原标题「继续探索更多精彩吧」把这层意思整个盖住了,
|
|
300
|
+
* 当前说明同时提示选择偏好与直接表达两种输入方式,偏好采集行为保持不变。
|
|
301
|
+
*/}
|
|
302
|
+
<div className="member-card-summary__preferences">
|
|
303
|
+
<strong>
|
|
304
|
+
{/* 跟随标题字号:size 走 SVG 属性、不经 rem 换算,不跟就会比标题还大。 */}
|
|
305
|
+
<Sparkle size={15} weight="fill" aria-hidden="true" />
|
|
306
|
+
<span>说说你的偏好</span>
|
|
307
|
+
</strong>
|
|
308
|
+
<p className="member-card-summary__preference-hint">请选择你的偏好,或直接告诉我</p>
|
|
309
|
+
<div className="member-card-summary__preference-list">
|
|
310
|
+
{preferencePrompts.map((prompt) => (
|
|
311
|
+
<button
|
|
312
|
+
key={prompt.value}
|
|
313
|
+
type="button"
|
|
314
|
+
disabled={isHistory || !cardId}
|
|
315
|
+
onClick={() => sendCardAction(cardId, {
|
|
316
|
+
type: 'sendMessage',
|
|
317
|
+
value: prompt.query,
|
|
318
|
+
displayText: prompt.value,
|
|
319
|
+
visible: true,
|
|
320
|
+
})}
|
|
321
|
+
>
|
|
322
|
+
<span>{prompt.value}</span>
|
|
323
|
+
</button>
|
|
324
|
+
))}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
{pointsRuleOpen && (data.pointsRuleImageUrl || data.pointsRuleContent) && createPortal(
|
|
328
|
+
<div
|
|
329
|
+
className={`member-points-rule${pointsRuleClosing ? ' member-points-rule--closing' : ''}`}
|
|
330
|
+
role="dialog"
|
|
331
|
+
aria-modal="true"
|
|
332
|
+
aria-labelledby="member-points-rule-title"
|
|
333
|
+
onAnimationEnd={(event) => {
|
|
334
|
+
if (pointsRuleClosing && event.target === event.currentTarget) finishClosePointsRule();
|
|
335
|
+
}}
|
|
336
|
+
onMouseDown={(event) => {
|
|
337
|
+
if (event.target === event.currentTarget) closePointsRule();
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
<div className="member-points-rule__panel">
|
|
341
|
+
<header className="member-points-rule__header">
|
|
342
|
+
<strong id="member-points-rule-title">积分规则</strong>
|
|
343
|
+
<button
|
|
344
|
+
ref={pointsRuleCloseRef}
|
|
345
|
+
type="button"
|
|
346
|
+
className="member-points-rule__close"
|
|
347
|
+
aria-label="关闭积分规则"
|
|
348
|
+
onClick={closePointsRule}
|
|
349
|
+
>
|
|
350
|
+
<X size={20} weight="bold" aria-hidden="true" />
|
|
351
|
+
</button>
|
|
352
|
+
</header>
|
|
353
|
+
<div className="member-points-rule__body">
|
|
354
|
+
{data.pointsRuleImageUrl ? <ImageWithFallback src={data.pointsRuleImageUrl} alt="积分规则详情" className="member-points-rule__image" size={96} loading="eager" /> : <div className="member-points-rule__content">{renderBenefitText(data.pointsRuleContent ?? '')}</div>}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>,
|
|
358
|
+
document.body,
|
|
359
|
+
)}
|
|
360
|
+
{benefitAction && createPortal(
|
|
361
|
+
<div className="member-miniapp-dialog" role="dialog" aria-modal="true" aria-label={benefitAction.title ?? benefitAction.name} onMouseDown={(event) => { if (event.target === event.currentTarget) closeBenefitAction(); }}>
|
|
362
|
+
<div className={`member-miniapp-dialog__panel${benefitAction.url ? ' member-miniapp-dialog__panel--miniapp' : ' member-miniapp-dialog__panel--text'}${benefitAction.closing ? ' member-miniapp-dialog__panel--closing' : ''}`}>
|
|
363
|
+
<header className="member-miniapp-dialog__header"><strong>{benefitAction.title ?? benefitAction.name}</strong><button type="button" aria-label="关闭" onClick={closeBenefitAction}><X size={20} weight="bold" aria-hidden="true" /></button></header>
|
|
364
|
+
{benefitAction.url ? <iframe className="member-miniapp-dialog__frame" src={benefitAction.url} title={benefitAction.title ?? benefitAction.name} /> : <div className="member-miniapp-dialog__text">{renderBenefitText(benefitAction.text ?? '')}</div>}
|
|
365
|
+
{!benefitAction.url && <button type="button" className="member-miniapp-dialog__confirm" onClick={closeBenefitAction}>我知道了</button>}
|
|
366
|
+
</div>
|
|
367
|
+
</div>,
|
|
368
|
+
document.body,
|
|
369
|
+
)}
|
|
370
|
+
</section>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 原型中的会员卡以权益区收口,不展示 SDK 默认的通用动作条。
|
|
375
|
+
export default markSelfRendering(MemberCardSummaryCard);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** member-enrollment-cta:只呈现真实跳转入口,不预告未由 Provider 返回的会员权益。 */
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import { ArrowSquareOut, IdentificationCard, ShieldCheck, WarningCircle } from '@phosphor-icons/react';
|
|
4
|
+
import { sendCardAction } from '@tbox.cn/app-sdk/client';
|
|
5
|
+
import type { MemberEnrollmentCtaCardData } from '@tbox.cn/app-contracts-mall';
|
|
6
|
+
|
|
7
|
+
export function MemberEnrollmentCtaCard(props: {
|
|
8
|
+
data: MemberEnrollmentCtaCardData;
|
|
9
|
+
cardId: string;
|
|
10
|
+
isHistory: boolean;
|
|
11
|
+
}) {
|
|
12
|
+
const { data, cardId, isHistory } = props;
|
|
13
|
+
const [opening, setOpening] = useState(false);
|
|
14
|
+
const openingRef = useRef(false);
|
|
15
|
+
|
|
16
|
+
function handleEnroll() {
|
|
17
|
+
// 深链为可选透传素材(schema optional);无 URL 时不发 openLink(壳能力/门禁接管)
|
|
18
|
+
if (isHistory || openingRef.current || !data.enrollUrl) return;
|
|
19
|
+
openingRef.current = true;
|
|
20
|
+
setOpening(true);
|
|
21
|
+
sendCardAction(cardId, { type: 'openLink', url: data.enrollUrl });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<section className="member-card member-enroll-cta">
|
|
26
|
+
<div className="member-enroll-cta__hero">
|
|
27
|
+
<span className="member-enroll-cta__icon" aria-hidden="true">
|
|
28
|
+
<IdentificationCard size={28} weight="duotone" />
|
|
29
|
+
</span>
|
|
30
|
+
<span className="member-enroll-cta__eyebrow">会员服务</span>
|
|
31
|
+
<h4>{data.title ?? '开卡入会'}</h4>
|
|
32
|
+
<p>前往支付宝会员页面完成开卡,结果以页面为准</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div className="member-enroll-cta__footer">
|
|
36
|
+
<p className="member-enroll-cta__hint">
|
|
37
|
+
<ShieldCheck size={16} weight="fill" aria-hidden="true" />
|
|
38
|
+
登录状态不受入会结果影响,开卡结果以支付宝会员页面为准
|
|
39
|
+
</p>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className="member-enroll-cta__btn"
|
|
43
|
+
disabled={isHistory || opening || !data.enrollUrl}
|
|
44
|
+
onClick={handleEnroll}
|
|
45
|
+
aria-label={data.enrollUrl ? '立即开卡入会' : '入会暂不可用(请联系商场客服)'}
|
|
46
|
+
>
|
|
47
|
+
{opening ? '正在打开支付宝…' : data.enrollUrl ? '立即开卡入会' : '暂不可用(请联系商场客服)'}
|
|
48
|
+
{/* 有 URL 才暗示可跳转;无 URL(壳能力接管/未配置深链)用警示图标传达“不可点” */}
|
|
49
|
+
{data.enrollUrl ? (
|
|
50
|
+
<ArrowSquareOut size={18} weight="bold" aria-hidden="true" />
|
|
51
|
+
) : (
|
|
52
|
+
<WarningCircle size={18} weight="fill" aria-hidden="true" />
|
|
53
|
+
)}
|
|
54
|
+
</button>
|
|
55
|
+
{isHistory && <p className="member-enroll-cta__history">历史卡片不可重复操作</p>}
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default MemberEnrollmentCtaCard;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* member-enrollment-form 富卡(0.4.0 D6/D10):受控表单 + select 渲染 + readOnly 预填锁定
|
|
3
|
+
* + 用户显式授权(无 mount 副作用)+ 客户端必填/必选校验。
|
|
4
|
+
* payload 形状与 server inputSchema 一致(fieldValues Record / acceptedConsentIds / formVersion)。
|
|
5
|
+
*/
|
|
6
|
+
import { useRef, useState } from 'react';
|
|
7
|
+
import { Lock, ShieldCheck, UserPlus } from '@phosphor-icons/react';
|
|
8
|
+
import { sendCardAction, markSelfRendering } from '@tbox.cn/app-sdk/client';
|
|
9
|
+
import type { MemberEnrollmentFormCardData } from '@tbox.cn/app-contracts-mall';
|
|
10
|
+
|
|
11
|
+
const AUTO_AUTH_ACTION = 'member.request-alipay-authorization';
|
|
12
|
+
|
|
13
|
+
export function MemberEnrollmentFormCard(props: {
|
|
14
|
+
data: MemberEnrollmentFormCardData;
|
|
15
|
+
cardId: string;
|
|
16
|
+
isHistory: boolean;
|
|
17
|
+
actionTokens?: Record<string, string>;
|
|
18
|
+
}) {
|
|
19
|
+
const { data, cardId, isHistory, actionTokens } = props;
|
|
20
|
+
const [values, setValues] = useState<Record<string, string>>(() =>
|
|
21
|
+
Object.fromEntries(data.fields.map((f) => [f.fieldId, f.prefill ?? ''])),
|
|
22
|
+
);
|
|
23
|
+
const [consented, setConsented] = useState<Record<string, boolean>>({});
|
|
24
|
+
const [authRequesting, setAuthRequesting] = useState(false);
|
|
25
|
+
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
26
|
+
const [submitting, setSubmitting] = useState(false);
|
|
27
|
+
const authRequestingRef = useRef(false);
|
|
28
|
+
const submittingRef = useRef(false);
|
|
29
|
+
|
|
30
|
+
const authToken = actionTokens?.[AUTO_AUTH_ACTION];
|
|
31
|
+
const enrollToken = actionTokens?.['member.enroll'];
|
|
32
|
+
|
|
33
|
+
const fireAuth = () => {
|
|
34
|
+
if (!authToken || isHistory || authRequestingRef.current) return;
|
|
35
|
+
authRequestingRef.current = true;
|
|
36
|
+
setAuthRequesting(true);
|
|
37
|
+
sendCardAction(cardId, { type: 'moduleAction', actionId: AUTO_AUTH_ACTION, contextToken: authToken });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (data.state === 'active') {
|
|
41
|
+
return (
|
|
42
|
+
<section className="member-card member-enrollment-form">
|
|
43
|
+
<div className="member-enrollment-form__done">
|
|
44
|
+
<span className="member-enrollment-form__done-icon"><ShieldCheck size={30} weight="duotone" /></span>
|
|
45
|
+
<span><small>会员状态</small><strong>您已是会员</strong></span>
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const missingFields = data.fields.filter((field) => field.required && !(values[field.fieldId] ?? '').trim());
|
|
52
|
+
const missingConsents = data.consents.filter((consent) => consent.required && !consented[consent.consentId]);
|
|
53
|
+
const submit = () => {
|
|
54
|
+
if (!enrollToken || isHistory || submittingRef.current) return;
|
|
55
|
+
setSubmitAttempted(true);
|
|
56
|
+
if (missingFields.length > 0 || missingConsents.length > 0) return;
|
|
57
|
+
submittingRef.current = true;
|
|
58
|
+
setSubmitting(true);
|
|
59
|
+
sendCardAction(
|
|
60
|
+
cardId,
|
|
61
|
+
{ type: 'moduleAction', actionId: 'member.enroll', contextToken: enrollToken },
|
|
62
|
+
{
|
|
63
|
+
fieldValues: values,
|
|
64
|
+
acceptedConsentIds: data.consents.filter((c) => consented[c.consentId]).map((c) => c.consentId),
|
|
65
|
+
formVersion: data.formVersion,
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const authStatus = data.authorization.status;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<section className="member-card member-enrollment-form">
|
|
74
|
+
<header className="member-enrollment-form__head">
|
|
75
|
+
<span className="member-enrollment-form__head-icon"><UserPlus size={22} weight="duotone" /></span>
|
|
76
|
+
<div>
|
|
77
|
+
<span>会员</span>
|
|
78
|
+
<strong>开卡入会</strong>
|
|
79
|
+
</div>
|
|
80
|
+
</header>
|
|
81
|
+
<p className="member-enrollment-form__subtitle">完善基础资料,开启会员服务</p>
|
|
82
|
+
|
|
83
|
+
{/* 授权条三态:授权只由用户显式点击触发,不在 mount 时拉起系统授权。 */}
|
|
84
|
+
<div className={`member-enrollment-form__auth member-enrollment-form__auth--${authStatus}`}>
|
|
85
|
+
{authStatus === 'authorized' ? (
|
|
86
|
+
<>
|
|
87
|
+
<ShieldCheck size={16} />
|
|
88
|
+
<span>
|
|
89
|
+
已通过支付宝授权预填手机号
|
|
90
|
+
{data.authorization.mobileMasked ? `(${data.authorization.mobileMasked})` : ''}
|
|
91
|
+
</span>
|
|
92
|
+
</>
|
|
93
|
+
) : authStatus === 'required' ? (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
className="member-enrollment-form__auth-btn"
|
|
97
|
+
disabled={isHistory || !authToken || authRequesting}
|
|
98
|
+
onClick={fireAuth}
|
|
99
|
+
>
|
|
100
|
+
<ShieldCheck size={16} />
|
|
101
|
+
{authRequesting ? '正在请求支付宝授权…' : '支付宝授权并填充'}
|
|
102
|
+
</button>
|
|
103
|
+
) : (
|
|
104
|
+
<span>支付宝授权不可用,请手动填写</span>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
{data.manualNotice && <p className="member-enrollment-form__notice">{data.manualNotice}</p>}
|
|
108
|
+
|
|
109
|
+
{data.fields.map((f) => (
|
|
110
|
+
<div key={f.fieldId} className="member-ef-field">
|
|
111
|
+
<label className="member-ef-field__label">
|
|
112
|
+
{f.label}
|
|
113
|
+
{f.required && <em>*</em>}
|
|
114
|
+
</label>
|
|
115
|
+
{f.type === 'select' ? (
|
|
116
|
+
<select
|
|
117
|
+
className="member-ef-field__control"
|
|
118
|
+
value={values[f.fieldId] ?? ''}
|
|
119
|
+
disabled={f.readOnly || isHistory}
|
|
120
|
+
onChange={(e) => setValues((v) => ({ ...v, [f.fieldId]: e.target.value }))}
|
|
121
|
+
>
|
|
122
|
+
<option value="">请选择</option>
|
|
123
|
+
{(f.options ?? []).map((opt) => (
|
|
124
|
+
<option key={opt} value={opt}>
|
|
125
|
+
{opt}
|
|
126
|
+
</option>
|
|
127
|
+
))}
|
|
128
|
+
</select>
|
|
129
|
+
) : (
|
|
130
|
+
<input
|
|
131
|
+
className="member-ef-field__control"
|
|
132
|
+
type={f.type === 'tel' ? 'tel' : f.type === 'date' ? 'date' : 'text'}
|
|
133
|
+
value={values[f.fieldId] ?? ''}
|
|
134
|
+
readOnly={f.readOnly || isHistory}
|
|
135
|
+
placeholder={f.placeholder ?? ''}
|
|
136
|
+
onChange={(e) => setValues((v) => ({ ...v, [f.fieldId]: e.target.value }))}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
{f.readOnly && <Lock className="member-ef-field__lock" size={13} weight="fill" />}
|
|
140
|
+
{submitAttempted && f.required && !(values[f.fieldId] ?? '').trim() && (
|
|
141
|
+
<p className="member-ef-field__error" role="alert">请填写{f.label}</p>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
))}
|
|
145
|
+
|
|
146
|
+
<div className="member-ef-consents">
|
|
147
|
+
{data.consents.map((c) => (
|
|
148
|
+
<label key={c.consentId} className="member-ef-consents__item">
|
|
149
|
+
<input
|
|
150
|
+
type="checkbox"
|
|
151
|
+
checked={!!consented[c.consentId]}
|
|
152
|
+
disabled={isHistory}
|
|
153
|
+
onChange={(e) => setConsented((s) => ({ ...s, [c.consentId]: e.target.checked }))}
|
|
154
|
+
/>
|
|
155
|
+
<span>
|
|
156
|
+
{c.title}
|
|
157
|
+
{c.required && <em>(必选)</em>}
|
|
158
|
+
</span>
|
|
159
|
+
</label>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
{submitAttempted && missingConsents.length > 0 && (
|
|
163
|
+
<p className="member-ef-validation" role="alert">
|
|
164
|
+
请先同意:{missingConsents.map((consent) => consent.title).join('、')}
|
|
165
|
+
</p>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
className="member-ef-submit"
|
|
171
|
+
disabled={isHistory || !enrollToken || submitting}
|
|
172
|
+
onClick={submit}
|
|
173
|
+
>
|
|
174
|
+
{submitting ? '正在提交…' : '提交入会'}
|
|
175
|
+
</button>
|
|
176
|
+
</section>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 0.4.0 D10 评审补位:提交/授权按钮全在卡内(依赖表单/授权上下文),自持动作跳过 SDK 默认按钮条
|
|
181
|
+
export default markSelfRendering(MemberEnrollmentFormCard);
|