@tbox.cn/app-module-promotion 0.2.0 → 0.9.3
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 +90 -11
- package/dist/chunk-P7IEEV37.js +1347 -0
- package/dist/chunk-QOTX4XDS.js +1745 -0
- package/dist/client/index.d.ts +21 -3
- package/dist/client/index.js +4 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.js +16 -4
- package/dist/promotion-CMKJ4DB3.css +2135 -0
- package/dist/promotion-activity-header-pea-v2-4CCHFCCM.jpg +0 -0
- package/dist/promotion-coupon-header-pea-v1-4CCHFCCM.jpg +0 -0
- package/dist/server/index.d.ts +2481 -41
- package/dist/server/index.js +13 -3
- package/package.json +28 -15
- package/skills/promotion/SKILL.md +137 -0
- package/skills/promotion/references/claim-loop.md +18 -0
- package/src/client/assets/promotion-activity-header-pea-v2.jpg +0 -0
- package/src/client/assets/promotion-coupon-header-pea-v1.jpg +0 -0
- package/src/client/assets.d.ts +5 -0
- package/src/client/cards/index.ts +10 -0
- package/src/client/cards/promotion-activity-category-list/index.tsx +34 -0
- package/src/client/cards/promotion-activity-detail/index.tsx +84 -0
- package/src/client/cards/promotion-activity-list/index.tsx +58 -0
- package/src/client/cards/promotion-best-deal/index.tsx +18 -0
- package/src/client/cards/promotion-entitlement-list/index.tsx +163 -0
- package/src/client/cards/promotion-offer-detail/index.tsx +116 -0
- package/src/client/cards/promotion-offer-list/index.tsx +285 -0
- package/src/client/cards/view.ts +72 -0
- package/src/client/components/CouponQrCode.tsx +48 -0
- package/src/client/components/CouponSheet.tsx +124 -0
- package/src/client/components/ImageWithFallback.tsx +39 -0
- package/src/client/index.ts +23 -4
- package/src/client/styles/promotion.css +2135 -0
- package/src/client/types/css.d.ts +2 -0
- package/src/client/utils/sanitize-activity-html.ts +48 -0
- package/src/client/utils/scroll-card-bottom.ts +40 -0
- package/src/server/actions.ts +217 -0
- package/src/server/cards/index.ts +19 -0
- package/src/server/cards/promotion-activity-category-list/meta.ts +13 -0
- package/src/server/cards/promotion-activity-detail/meta.ts +29 -0
- package/src/server/cards/promotion-activity-list/meta.ts +13 -0
- package/src/server/cards/promotion-best-deal/meta.ts +20 -0
- package/src/server/cards/promotion-entitlement-list/meta.ts +17 -0
- package/src/server/cards/promotion-entitlement-list/samples.ts +51 -0
- package/src/server/cards/promotion-offer-detail/meta.ts +25 -0
- package/src/server/cards/promotion-offer-list/meta.ts +20 -0
- package/src/server/cards/promotion-offer-list/samples.ts +154 -0
- package/src/server/handler.ts +378 -16
- package/src/server/index.ts +117 -22
- package/src/server/navigation.ts +23 -0
- package/src/server/ports.ts +56 -0
- package/src/server/service.ts +243 -24
- package/src/server/tools.ts +789 -0
- package/tbox.module.json +126 -0
- package/tests/cards-render.test.tsx +656 -0
- package/tests/ports-resolve.test.ts +96 -0
- package/tests/pretool-coupons.test.ts +63 -0
- package/tests/promotion.test.ts +976 -0
- package/tests/samples.test.ts +42 -0
- package/tests/view.test.ts +61 -0
- package/tsup.config.ts +9 -1
- package/dist/chunk-AD6OLHSM.js +0 -86
- package/dist/chunk-LQGF6337.js +0 -32
- package/src/client/cards/coupon/index.tsx +0 -12
- package/src/server/cards/coupon/meta.ts +0 -23
- package/tbox.component.json +0 -20
- package/tests/service.test.ts +0 -31
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { CalendarBlank, CaretDown, Copy, MapPinLine, NavigationArrow, ShieldCheck, Ticket } from '@phosphor-icons/react';
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { callJsapi, httpGet } from '@tbox.cn/app-sdk/client';
|
|
4
|
+
import type { PromotionEntitlementListCardData } from '@tbox.cn/app-contracts-mall';
|
|
5
|
+
import { CouponSheet } from '../../components/CouponSheet';
|
|
6
|
+
import { CouponBarcode, CouponQrCode } from '../../components/CouponQrCode';
|
|
7
|
+
import type { SheetFact } from '../../components/CouponSheet';
|
|
8
|
+
import { ImageWithFallback } from '../../components/ImageWithFallback';
|
|
9
|
+
import couponHeaderArtUrl from '../../assets/promotion-coupon-header-pea-v1.jpg';
|
|
10
|
+
import { sanitizeActivityHtml } from '../../utils/sanitize-activity-html';
|
|
11
|
+
import { scrollCardBottom } from '../../utils/scroll-card-bottom';
|
|
12
|
+
import { entitlementStatusLabel } from '../view';
|
|
13
|
+
|
|
14
|
+
export function PromotionEntitlementListCard(props: {
|
|
15
|
+
data: PromotionEntitlementListCardData;
|
|
16
|
+
cardId?: string;
|
|
17
|
+
isHistory?: boolean;
|
|
18
|
+
}) {
|
|
19
|
+
const { data } = props;
|
|
20
|
+
const [openRef, setOpenRef] = useState<string | null>(null);
|
|
21
|
+
const [expanded, setExpanded] = useState(false);
|
|
22
|
+
const [listHeights, setListHeights] = useState({ collapsed: 0, expanded: 0 });
|
|
23
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const initialRenderRef = useRef(true);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (initialRenderRef.current) {
|
|
28
|
+
initialRenderRef.current = false;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (bottomRef.current) scrollCardBottom(bottomRef.current);
|
|
32
|
+
}, [expanded]);
|
|
33
|
+
useLayoutEffect(() => {
|
|
34
|
+
const list = listRef.current;
|
|
35
|
+
if (!list) return;
|
|
36
|
+
const rows = [...list.children] as HTMLElement[];
|
|
37
|
+
const boundary = rows[Math.min(maxItems, rows.length) - 1];
|
|
38
|
+
const listTop = list.getBoundingClientRect().top;
|
|
39
|
+
const collapsed = boundary ? boundary.getBoundingClientRect().bottom - listTop : list.scrollHeight;
|
|
40
|
+
setListHeights({ collapsed, expanded: list.scrollHeight });
|
|
41
|
+
}, [data.items.length, data.maxItems]);
|
|
42
|
+
const toggleExpanded = () => {
|
|
43
|
+
setExpanded((value) => !value);
|
|
44
|
+
};
|
|
45
|
+
const target = openRef ? data.items.find((item) => item.entitlementRef === openRef) : undefined;
|
|
46
|
+
const [detailTarget, setDetailTarget] = useState<typeof target>();
|
|
47
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
48
|
+
const detailController = useRef<AbortController | null>(null);
|
|
49
|
+
const [copied, setCopied] = useState(false);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
detailController.current?.abort();
|
|
52
|
+
if (!openRef || !target) {
|
|
53
|
+
setDetailTarget(undefined);
|
|
54
|
+
setDetailLoading(false);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
detailController.current = controller;
|
|
59
|
+
setDetailTarget(undefined);
|
|
60
|
+
setDetailLoading(true);
|
|
61
|
+
const params = new URLSearchParams({ entitlementRef: openRef });
|
|
62
|
+
if (data.mallId) params.set('mallId', data.mallId);
|
|
63
|
+
if (data.miniAppId) params.set('miniAppId', data.miniAppId);
|
|
64
|
+
httpGet<{ data: NonNullable<typeof target> }>(`/api/m/promotion/entitlement-detail?${params.toString()}`, { signal: controller.signal })
|
|
65
|
+
.then(({ data: payload }) => { if (!controller.signal.aborted) setDetailTarget(payload.data); })
|
|
66
|
+
.catch(() => undefined)
|
|
67
|
+
.finally(() => { if (!controller.signal.aborted) setDetailLoading(false); });
|
|
68
|
+
return () => controller.abort();
|
|
69
|
+
}, [openRef, target]);
|
|
70
|
+
const sheetTarget = detailTarget ?? target;
|
|
71
|
+
const maxItems = data.maxItems ?? 4;
|
|
72
|
+
const copyCode = async (code: string) => {
|
|
73
|
+
try { await navigator.clipboard?.writeText(code); setCopied(true); window.setTimeout(() => setCopied(false), 1600); } catch { setCopied(false); }
|
|
74
|
+
};
|
|
75
|
+
return (
|
|
76
|
+
<section className="promotion-card promotion-list--coupon promotion-entitlements" aria-label="我的优惠权益">
|
|
77
|
+
<header className="promotion-activity__header promotion-activity__header--pea promotion-coupon__header">
|
|
78
|
+
<img src={couponHeaderArtUrl} alt="" aria-hidden="true" className="promotion-activity__header-art" />
|
|
79
|
+
<div className="promotion-activity__header-copy">
|
|
80
|
+
<div className="promotion-activity__header-title-row">
|
|
81
|
+
<Ticket weight="fill" />
|
|
82
|
+
<h3>我的优惠券</h3>
|
|
83
|
+
</div>
|
|
84
|
+
<p>当前账户 · 券包权益</p>
|
|
85
|
+
</div>
|
|
86
|
+
</header>
|
|
87
|
+
{!data.items.length ? <div className="promotion-card__empty"><Ticket size={28} weight="duotone" /><span>{data.noResultsReason ?? '当前账户暂无可用权益'}</span></div> : (
|
|
88
|
+
<div ref={listRef} className={`promotion-list__items${expanded ? ' promotion-list__items--expanded' : ''}`} style={{ maxHeight: expanded ? listHeights.expanded || undefined : listHeights.collapsed || undefined }} onTransitionEnd={() => { if (bottomRef.current) scrollCardBottom(bottomRef.current); }}>
|
|
89
|
+
{data.items.map((item) => (
|
|
90
|
+
<article
|
|
91
|
+
key={item.entitlementRef}
|
|
92
|
+
className={`promotion-coupon-row${item.status === 'EFFECTIVE' ? '' : ' promotion-coupon-row--disabled'}`}
|
|
93
|
+
role="button"
|
|
94
|
+
tabIndex={0}
|
|
95
|
+
aria-label={`查看「${item.title}」`}
|
|
96
|
+
onClick={() => setOpenRef(item.entitlementRef)}
|
|
97
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenRef(item.entitlementRef); } }}
|
|
98
|
+
>
|
|
99
|
+
<ImageWithFallback src={item.imageUrl} alt={item.title} className="promotion-coupon-row__thumb" />
|
|
100
|
+
<div className="promotion-coupon-row__main">
|
|
101
|
+
<strong className="promotion-coupon-row__title">{item.title}</strong>
|
|
102
|
+
{item.usageNotice
|
|
103
|
+
&& <span className="promotion-coupon-row__sub" dangerouslySetInnerHTML={{ __html: sanitizeActivityHtml(item.usageNotice) }} />}
|
|
104
|
+
<span className="promotion-coupon-row__price">
|
|
105
|
+
<b>{item.benefitText ?? entitlementStatusLabel(item.status)}</b>
|
|
106
|
+
{item.thresholdText && <em className="promotion-coupon-row__discount">{item.thresholdText}</em>}
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
<span
|
|
110
|
+
className={`promotion-owned-action promotion-chip promotion-chip--coupon promotion-coupon-row__state--${item.status.toLowerCase()}${item.status === 'EFFECTIVE' ? '' : ' promotion-chip--coupon-off'}`}
|
|
111
|
+
aria-hidden="true"
|
|
112
|
+
>
|
|
113
|
+
{item.status === 'EFFECTIVE' ? '去使用' : entitlementStatusLabel(item.status)}
|
|
114
|
+
</span>
|
|
115
|
+
</article>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
{data.items.length > maxItems && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
className="promotion-list__toggle"
|
|
123
|
+
aria-expanded={expanded}
|
|
124
|
+
onClick={toggleExpanded}
|
|
125
|
+
>
|
|
126
|
+
<CaretDown size={16} data-rotated={expanded || undefined} />
|
|
127
|
+
{expanded ? '收起' : `查看更多(${data.items.length - maxItems} 条)`}
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
{data.items.length > maxItems && <div ref={bottomRef} className="promotion-list__bottom-anchor" aria-hidden="true" />}
|
|
131
|
+
{sheetTarget && (() => {
|
|
132
|
+
const facts = [
|
|
133
|
+
sheetFact(<MapPinLine weight="duotone" />, '使用场景', sheetTarget.usageSceneText),
|
|
134
|
+
sheetFact(<CalendarBlank weight="duotone" />, '有效期', sheetTarget.validityText ?? sheetTarget.validUntilText),
|
|
135
|
+
sheetFact(<CalendarBlank weight="duotone" />, '使用时间', sheetTarget.usageTimeText),
|
|
136
|
+
sheetFact(<ShieldCheck weight="duotone" />, '使用限制', sheetTarget.thresholdText),
|
|
137
|
+
sheetFact(<CalendarBlank weight="duotone" />, '可领取时间', sheetTarget.claimTimeText),
|
|
138
|
+
sheetFact(<Ticket weight="duotone" />, '使用规则', sheetTarget.usageRuleText),
|
|
139
|
+
].filter(Boolean) as SheetFact[];
|
|
140
|
+
return (
|
|
141
|
+
<CouponSheet
|
|
142
|
+
title={sheetTarget.title}
|
|
143
|
+
imageUrl={sheetTarget.imageUrl}
|
|
144
|
+
priceMain={sheetTarget.benefitText}
|
|
145
|
+
aside={detailLoading ? '加载详情中' : entitlementStatusLabel(sheetTarget.status)}
|
|
146
|
+
noticeText={sheetTarget.usageNotice}
|
|
147
|
+
guaranteeText={sheetTarget.guaranteeText}
|
|
148
|
+
facts={facts}
|
|
149
|
+
onClose={() => setOpenRef(null)}
|
|
150
|
+
beforeFacts={<>{sheetTarget.qrCodeEnabled && sheetTarget.code && <><CouponBarcode value={sheetTarget.code} disabled={sheetTarget.status !== 'EFFECTIVE'} /><CouponQrCode value={sheetTarget.code} disabled={sheetTarget.status !== 'EFFECTIVE'} /></>}{(sheetTarget.code || sheetTarget.codeMasked) && <div className={`promotion-sheet__code${sheetTarget.status === 'EFFECTIVE' ? '' : ' promotion-sheet__code--disabled'}`}><span>券码</span><strong>{sheetTarget.code || sheetTarget.codeMasked}</strong><button type="button" className="promotion-sheet__copy" onClick={() => copyCode(sheetTarget.code || sheetTarget.codeMasked!)}><Copy size={16} />{copied ? '已复制' : '复制'}</button></div>}{sheetTarget.stores?.length ? <><div className="promotion-sheet__section">适用商家</div><div className="promotion-sheet__stores">{sheetTarget.stores.map((store) => <div className="promotion-sheet__store" key={store.storeId}>{store.imageUrl && <ImageWithFallback src={store.imageUrl} alt={store.storeName} className="promotion-sheet__store-image" />}<span><strong>{store.storeName}</strong>{store.businessName && store.businessName !== store.storeName && <small>{store.businessName}</small>}{store.floor && <small>{store.floor}</small>}{store.address && <small>{store.address}</small>}</span>{store.latitude !== undefined && store.longitude !== undefined && <button type="button" className="promotion-sheet__navigate" aria-label={`导航到${store.storeName}`} onClick={(event) => { event.stopPropagation(); void callJsapi('openLocation', { latitude: store.latitude, longitude: store.longitude, name: store.storeName, address: store.address ?? '' }).catch(() => undefined); }}><NavigationArrow size={18} />导航</button>}</div>)}</div></> : null}</>}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
})()}
|
|
154
|
+
</section>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 缺字段的条目整条不出——详情请求失败时保留列表摘要,不编造空字段。 */
|
|
159
|
+
function sheetFact(icon: React.ReactNode, label: string, value?: string): SheetFact | null {
|
|
160
|
+
return value ? { icon, label, value } : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default PromotionEntitlementListCard;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { CalendarBlank, CheckCircle, Clock, Gift, Info, ListChecks, MapPin, SealCheck, Ticket } from '@phosphor-icons/react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import type { PromotionOfferDetailCardData } from '@tbox.cn/app-contracts-mall';
|
|
4
|
+
import { markSelfRendering, navigateToAlipayPage, sendCardAction } from '@tbox.cn/app-sdk/client';
|
|
5
|
+
import { ImageWithFallback } from '../../components/ImageWithFallback';
|
|
6
|
+
import { offerTypeLabel } from '../view';
|
|
7
|
+
import { sanitizeActivityHtml } from '../../utils/sanitize-activity-html';
|
|
8
|
+
|
|
9
|
+
const modeText: Record<string, string> = {
|
|
10
|
+
AUTOMATIC_DISCOUNT: '结算时自动优惠',
|
|
11
|
+
CLAIM_THEN_PURCHASE: '先领券,再购买',
|
|
12
|
+
REGISTER_THEN_ATTEND: '先报名,再到场',
|
|
13
|
+
GROUP_PURCHASE: '发起或参与拼团',
|
|
14
|
+
UNKNOWN: '以活动页说明为准',
|
|
15
|
+
};
|
|
16
|
+
function isClaimSuccess(text: string, level?: string): boolean {
|
|
17
|
+
return level === 'success' || /领取成功|已存入卡包|已领取/.test(text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function PromotionOfferDetailCard({ data, cardId = '', isHistory = false, actionTokens }: { data: PromotionOfferDetailCardData; cardId?: string; isHistory?: boolean; actionTokens?: Record<string, string> }) {
|
|
21
|
+
const [acquiring, setAcquiring] = useState(false);
|
|
22
|
+
const [claimed, setClaimed] = useState(false);
|
|
23
|
+
const [claimMessage, setClaimMessage] = useState<string | null>(null);
|
|
24
|
+
const pendingClaimRef = useRef(false);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!cardId || typeof window === 'undefined') return undefined;
|
|
27
|
+
const onNotice = (event: Event) => {
|
|
28
|
+
const detail = (event as CustomEvent<{ surfaceId?: string; text?: string; level?: string }>).detail;
|
|
29
|
+
if (!detail?.text || (detail.surfaceId && detail.surfaceId !== cardId)) return;
|
|
30
|
+
setAcquiring(false);
|
|
31
|
+
pendingClaimRef.current = false;
|
|
32
|
+
if (isClaimSuccess(detail.text, detail.level)) {
|
|
33
|
+
setClaimed(true);
|
|
34
|
+
}
|
|
35
|
+
setClaimMessage(detail.text);
|
|
36
|
+
};
|
|
37
|
+
const onCard = (event: Event) => {
|
|
38
|
+
const card = (event as CustomEvent<{ cardType?: string }>).detail;
|
|
39
|
+
if (card?.cardType !== 'promotion-entitlement-list' || !pendingClaimRef.current) return;
|
|
40
|
+
pendingClaimRef.current = false;
|
|
41
|
+
setAcquiring(false);
|
|
42
|
+
setClaimed(true);
|
|
43
|
+
setClaimMessage('领取成功!已存入卡包');
|
|
44
|
+
};
|
|
45
|
+
window.addEventListener('tbox:notice', onNotice);
|
|
46
|
+
window.addEventListener('tbox:card', onCard);
|
|
47
|
+
return () => {
|
|
48
|
+
window.removeEventListener('tbox:notice', onNotice);
|
|
49
|
+
window.removeEventListener('tbox:card', onCard);
|
|
50
|
+
};
|
|
51
|
+
}, [cardId]);
|
|
52
|
+
if (data.presentation === 'promotion-guide') return <PromotionGuide data={data} />;
|
|
53
|
+
// 已领取形态随券详情一起退场:我的券由 entitlement-list 的行内浮层承载,
|
|
54
|
+
// 服务端已无 entitlement presentation 与 owned* 字段的产出点
|
|
55
|
+
const coupon = data.offerType === 'COUPON' || data.presentation === 'coupon';
|
|
56
|
+
// 抢购券严禁回退到免费 acquire;缺少深链时也必须停在明确错误态。
|
|
57
|
+
const purchaseOnly = coupon && data.claimActionLabel === '抢购';
|
|
58
|
+
const label = data.presentation === 'promotion' ? '本周活动' : data.presentation === 'package' ? '组合礼包' : '会员专享';
|
|
59
|
+
return (
|
|
60
|
+
<section className={`promotion-card promotion-detail${coupon ? ' promotion-detail--coupon' : ''}`} aria-label={data.title}>
|
|
61
|
+
<div className="promotion-detail__hero">
|
|
62
|
+
<ImageWithFallback src={data.imageUrl} alt={data.title} className="promotion-detail__image" />
|
|
63
|
+
<span className="promotion-detail__hero-label">{label}</span>
|
|
64
|
+
<div className="promotion-detail__glass"><small>{data.claimable ? '可领取' : '详情'}</small><strong>{data.benefitText ?? offerTypeLabel(data.offerType)}</strong><em>{data.subtitle ?? '当前商场可用'}</em></div>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="promotion-detail__body">
|
|
67
|
+
<div className="promotion-detail__status"><span className={`promotion-status promotion-status--${data.claimable ? 'available' : 'unavailable'}`}><SealCheck weight="fill" />{data.claimable ? '可领取' : '暂不可领'}</span>{data.validityText && <small><Clock />{data.validityText}</small>}</div>
|
|
68
|
+
<h3>{data.title}</h3>
|
|
69
|
+
{(data.usageNoticeText || data.guaranteeText) && <div className="promotion-detail__notice-strip">
|
|
70
|
+
{data.usageNoticeText && <span><b>须知</b>{data.usageNoticeText}</span>}
|
|
71
|
+
{data.guaranteeText && <span><b>保障</b>{data.guaranteeText}</span>}
|
|
72
|
+
</div>}
|
|
73
|
+
{(data.usageSceneText || data.validityText || data.usageTimeText || data.thresholdText || data.claimTimeText) && <div className="promotion-detail__facts">
|
|
74
|
+
{data.usageSceneText && <div><dt><MapPin weight="duotone" />使用场景</dt><dd>{data.usageSceneText}</dd></div>}
|
|
75
|
+
{data.validityText && <div><dt><CalendarBlank weight="duotone" />有效期</dt><dd>{data.validityText}</dd></div>}
|
|
76
|
+
{data.usageTimeText && <div><dt><Clock weight="duotone" />使用时间</dt><dd>{data.usageTimeText}</dd></div>}
|
|
77
|
+
{data.thresholdText && <div><dt><Ticket weight="duotone" />使用限制</dt><dd>{data.thresholdText}</dd></div>}
|
|
78
|
+
{data.claimTimeText && <div><dt><CalendarBlank weight="duotone" />可领取时间</dt><dd>{data.claimTimeText}</dd></div>}
|
|
79
|
+
</div>}
|
|
80
|
+
{(data.steps?.length ?? 0) > 0 && <div className="promotion-detail__rules">{data.steps?.slice(0, 3).map((step, index) => <span key={index}><CheckCircle weight="fill" /><b>{step.title}</b><small>{step.description ?? ''}</small></span>)}</div>}
|
|
81
|
+
{(data.usageRuleText || data.description) && <div className="promotion-detail__usage-rule"><strong>使用规则</strong><div dangerouslySetInnerHTML={{ __html: sanitizeActivityHtml(data.usageRuleText ?? data.description ?? '') }} /></div>}
|
|
82
|
+
{purchaseOnly ? <button className="promotion-coupon-main-action" type="button" disabled={!data.purchaseUrl || isHistory} onClick={() => { if (!data.purchaseUrl) return; void navigateToAlipayPage(data.purchaseUrl).catch((err: unknown) => console.warn('[PromotionOfferDetailCard] navigateTo failed', err)); }}>{data.purchaseUrl ? '去小程序抢购' : '抢购链接暂不可用'}</button>
|
|
83
|
+
: data.claimable && actionTokens?.['promotion.acquire'] && <button className={`promotion-coupon-main-action${acquiring ? ' promotion-coupon-main-action--pending' : ''}`} type="button" disabled={acquiring || claimed} onClick={() => { pendingClaimRef.current = true; setAcquiring(true); setClaimMessage('领取中,请稍候;领取成功后将自动展示我的券包'); sendCardAction(cardId, { type: 'moduleAction', actionId: 'promotion.acquire', contextToken: actionTokens['promotion.acquire'] }, { offerRef: data.offerRef }); }}>{claimed ? '已领取' : acquiring ? '领取中…' : '立即领取'}</button>}
|
|
84
|
+
{claimMessage && <p className="promotion-detail__claim-status" role="status">{claimMessage}</p>}
|
|
85
|
+
{(data.bundleItems?.length ?? 0) > 0 && <div className="promotion-detail__bundle">{data.bundleItems?.map((item, index) => <span key={index}><Gift weight="duotone" /><b>{item.name}</b><small>x{item.quantity}</small></span>)}</div>}
|
|
86
|
+
{data.notice && <div className="promotion-detail__notice"><Info />{data.notice}</div>}
|
|
87
|
+
{(data.stores?.length ?? 0) > 0 && <div className="promotion-detail__stores"><strong>适用门店</strong>{data.stores?.slice(0, 8).map((store) => <span key={store.storeId}><MapPin weight="fill" />{store.storeName}{store.floor ? ` · ${store.floor}` : ''}{store.address ? ` · ${store.address}` : ''}</span>)}</div>}
|
|
88
|
+
{!data.claimable && data.unavailableReason && <div className="promotion-detail__notice"><Info />{data.unavailableReason}</div>}
|
|
89
|
+
{data.caveats.length > 0 && <footer className="promotion-card__footer">{data.caveats.join(';')}</footer>}
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function PromotionGuide({ data }: { data: PromotionOfferDetailCardData }) {
|
|
96
|
+
const steps: Array<{ title: string; description?: string }> =
|
|
97
|
+
data.steps ?? data.rules.map((rule) => ({ title: rule }));
|
|
98
|
+
return (
|
|
99
|
+
<section className="promotion-card promotion-guide" aria-label="活动参与方式">
|
|
100
|
+
<header className="promotion-card__header promotion-card__header--compact">
|
|
101
|
+
<span className="promotion-card__mark promotion-card__mark--cyan"><ListChecks weight="duotone" /></span>
|
|
102
|
+
<div><span className="promotion-card__eyebrow">活动参与方式</span><h3>{data.title}</h3><p>{data.validityText ?? '当前活动有效期以现场为准'}</p></div>
|
|
103
|
+
<b className={`promotion-status promotion-status--${data.claimable ? 'available' : 'unavailable'}`}>{data.claimable ? '可参与' : '查看规则'}</b>
|
|
104
|
+
</header>
|
|
105
|
+
<div className="promotion-guide__summary"><span><MapPin weight="fill" />当前商场</span><strong>{modeText[data.participationMode ?? 'UNKNOWN']}</strong></div>
|
|
106
|
+
<ol>{steps.map((step, index) => <li key={index}><b>{index + 1}</b><span><strong>{step.title}</strong><small>{step.description ?? ''}</small></span></li>)}</ol>
|
|
107
|
+
{(data.requirements?.length ?? 0) > 0 && <div className="promotion-guide__requirements">{data.requirements?.map((item) => <span key={item}><CheckCircle weight="fill" />{item}</span>)}</div>}
|
|
108
|
+
{data.notice && <div className="promotion-detail__notice"><Info weight="duotone" />{data.notice}</div>}
|
|
109
|
+
<footer className="promotion-card__footer">参与资格和场次以活动现场实时信息为准</footer>
|
|
110
|
+
</section>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default PromotionOfferDetailCard;
|
|
115
|
+
|
|
116
|
+
markSelfRendering(PromotionOfferDetailCard);
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { CalendarBlank, CalendarDots, CaretDown, Lightbulb, MapPin, Sparkle, Ticket } from '@phosphor-icons/react';
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { ItemActionsRow, markSelfRendering, navigateToAlipayPage, sendCardAction } from '@tbox.cn/app-sdk/client';
|
|
4
|
+
import type { PromotionOfferListCardData } from '@tbox.cn/app-contracts-mall';
|
|
5
|
+
import { CouponSheet } from '../../components/CouponSheet';
|
|
6
|
+
import type { SheetFact } from '../../components/CouponSheet';
|
|
7
|
+
import { ImageWithFallback } from '../../components/ImageWithFallback';
|
|
8
|
+
import couponHeaderArtUrl from '../../assets/promotion-coupon-header-pea-v1.jpg';
|
|
9
|
+
import { offerTypeLabel } from '../view';
|
|
10
|
+
import { scrollCardBottom } from '../../utils/scroll-card-bottom';
|
|
11
|
+
|
|
12
|
+
const statusText = (status?: string, claimable?: boolean) => {
|
|
13
|
+
if (status === 'REQUIRES_ACTION') return '需操作';
|
|
14
|
+
if (status === 'UNKNOWN') return '待确认';
|
|
15
|
+
return claimable ? '可领取' : '不可领';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const statusTone = (status?: string, claimable?: boolean) =>
|
|
19
|
+
status?.toLowerCase() ?? (claimable ? 'available' : 'unavailable');
|
|
20
|
+
|
|
21
|
+
const couponSubtitleIsTime = (subtitle: string) => {
|
|
22
|
+
const plainText = subtitle.replace(/<[^>]+>/g, ' ').replace(/ /gi, ' ');
|
|
23
|
+
return /\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}|\d{1,2}:\d{2}/.test(plainText);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function PromotionOfferListCard(props: {
|
|
27
|
+
data: PromotionOfferListCardData;
|
|
28
|
+
cardId?: string;
|
|
29
|
+
isHistory?: boolean;
|
|
30
|
+
actionTokens?: Record<string, string>;
|
|
31
|
+
}) {
|
|
32
|
+
const { data, cardId = '', isHistory = false, actionTokens } = props;
|
|
33
|
+
const [openRef, setOpenRef] = useState<string | null>(null);
|
|
34
|
+
const [claimingRef, setClaimingRef] = useState<string | null>(null);
|
|
35
|
+
const [claimedRefs, setClaimedRefs] = useState<Set<string>>(() => new Set());
|
|
36
|
+
const [expanded, setExpanded] = useState(false);
|
|
37
|
+
const [listHeights, setListHeights] = useState({ collapsed: 0, expanded: 0 });
|
|
38
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const initialRenderRef = useRef(true);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (initialRenderRef.current) {
|
|
43
|
+
initialRenderRef.current = false;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (bottomRef.current) scrollCardBottom(bottomRef.current);
|
|
47
|
+
}, [expanded]);
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
const list = listRef.current;
|
|
50
|
+
if (!list) return;
|
|
51
|
+
const rows = [...list.children] as HTMLElement[];
|
|
52
|
+
const boundary = rows[Math.min(maxItems, rows.length) - 1];
|
|
53
|
+
const listTop = list.getBoundingClientRect().top;
|
|
54
|
+
const collapsed = boundary ? boundary.getBoundingClientRect().bottom - listTop : list.scrollHeight;
|
|
55
|
+
setListHeights({ collapsed, expanded: list.scrollHeight });
|
|
56
|
+
}, [data.items.length, data.maxItems]);
|
|
57
|
+
const toggleExpanded = () => {
|
|
58
|
+
setExpanded((value) => !value);
|
|
59
|
+
};
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!cardId || typeof window === 'undefined') return undefined;
|
|
62
|
+
const onNotice = (event: Event) => {
|
|
63
|
+
const detail = (event as CustomEvent<{ surfaceId?: string; text?: string; level?: string }>).detail;
|
|
64
|
+
if (!detail?.text || (detail.surfaceId && detail.surfaceId !== cardId)) return;
|
|
65
|
+
const ref = claimingRef;
|
|
66
|
+
if (!ref) return;
|
|
67
|
+
setClaimingRef(null);
|
|
68
|
+
if (detail.level === 'success' || /领取成功|已存入卡包|已领取/.test(detail.text)) {
|
|
69
|
+
setClaimedRefs((current) => new Set(current).add(ref));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
window.addEventListener('tbox:notice', onNotice);
|
|
73
|
+
return () => window.removeEventListener('tbox:notice', onNotice);
|
|
74
|
+
}, [cardId, claimingRef]);
|
|
75
|
+
if (data.presentation === 'recommendation') {
|
|
76
|
+
const [offer, ...alternatives] = data.items;
|
|
77
|
+
if (!offer) return <Empty text={data.noResultsReason ?? '暂未找到匹配当前场景的优惠'} />;
|
|
78
|
+
return (
|
|
79
|
+
<section className="promotion-card promotion-recommendation" aria-label="更适合你的优惠">
|
|
80
|
+
<header className="promotion-card__header">
|
|
81
|
+
<span className="promotion-card__mark promotion-card__mark--violet"><Sparkle weight="fill" /></span>
|
|
82
|
+
<div><span className="promotion-card__eyebrow">智能匹配</span><h3>更适合你的优惠</h3><p>根据当前可用性与预计节省排序</p></div>
|
|
83
|
+
</header>
|
|
84
|
+
<div className="promotion-recommendation__main">
|
|
85
|
+
<ImageWithFallback src={offer.imageUrl} alt={offer.title} className="promotion-recommendation__image" />
|
|
86
|
+
<div><span className={`promotion-status promotion-status--${statusTone(offer.availabilityStatus, offer.claimable)}`}>{statusText(offer.availabilityStatus, offer.claimable)}</span><strong>{offer.title}</strong><small>{offer.subtitle ?? offer.validityText ?? '当前商场可用'}</small></div>
|
|
87
|
+
<em>{offer.benefitText ?? offerTypeLabel(offer.offerType)}</em>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="promotion-recommendation__reason"><Lightbulb weight="duotone" /><span><b>推荐依据</b>{data.summary ?? '综合资格、可用性和优惠力度'}</span></div>
|
|
90
|
+
{alternatives.length > 0 && <div className="promotion-recommendation__alternatives">另有 {alternatives.length} 个候选优惠</div>}
|
|
91
|
+
<ItemActionsRow actions={offer.itemActions ?? []} cardId={cardId} isHistory={isHistory} className="promotion-recommendation__actions" chipClassName="promotion-button promotion-button--primary" />
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const promotion = data.presentation === 'promotion';
|
|
97
|
+
const hero = data.items[0];
|
|
98
|
+
const coupon = data.presentation === 'coupon' || data.mode === 'claimable';
|
|
99
|
+
const title = promotion ? '本周营销活动' : coupon ? '商场神券' : '活动与优惠';
|
|
100
|
+
const eyebrow = promotion ? '活动发现' : coupon ? '当前商场 · 优惠精选' : '优惠精选';
|
|
101
|
+
// summary 由服务端下发,可能与 eyebrow 撞词(券列表实测就撞了)——同一句话不占两行
|
|
102
|
+
const summary = data.summary && data.summary !== eyebrow ? data.summary : undefined;
|
|
103
|
+
// 只在「可领券列表」里滤掉领不了的(列出来用户点一次才知道点不动);
|
|
104
|
+
// 定向出某一张券时不过滤——那是用户指名要看的,滤掉就成了空列表
|
|
105
|
+
const items = data.mode === 'claimable' ? data.items.filter((item) => item.claimable) : data.items;
|
|
106
|
+
const maxItems = data.maxItems ?? 4;
|
|
107
|
+
return (
|
|
108
|
+
<section className={`promotion-card promotion-list${promotion ? ' promotion-list--promotion' : ''}${coupon ? ' promotion-list--coupon promotion-list--offers' : ''}`} aria-label={title}>
|
|
109
|
+
{promotion && hero && <div className="promotion-list__hero"><ImageWithFallback src={hero.imageUrl} alt={title} className="promotion-list__hero-image" /><div className="promotion-list__glass"><span>刚刚更新</span><strong>{data.items.length} 个活动正在进行</strong></div></div>}
|
|
110
|
+
{coupon ? (
|
|
111
|
+
<header className="promotion-activity__header promotion-activity__header--pea promotion-coupon__header">
|
|
112
|
+
<img src={couponHeaderArtUrl} alt="" aria-hidden="true" className="promotion-activity__header-art" />
|
|
113
|
+
<div className="promotion-activity__header-copy">
|
|
114
|
+
<div className="promotion-activity__header-title-row">
|
|
115
|
+
<Ticket weight="fill" />
|
|
116
|
+
<h3>{title}</h3>
|
|
117
|
+
</div>
|
|
118
|
+
<p>{eyebrow}</p>
|
|
119
|
+
</div>
|
|
120
|
+
</header>
|
|
121
|
+
) : (
|
|
122
|
+
<header className="promotion-card__header">
|
|
123
|
+
<span className="promotion-card__mark">{promotion ? <CalendarDots weight="duotone" /> : <Sparkle weight="fill" />}</span>
|
|
124
|
+
<div><span className="promotion-card__eyebrow">{eyebrow}</span><h3>{title}</h3><p>{summary ?? '根据当前场景实时筛选'}</p></div>
|
|
125
|
+
<b className="promotion-card__count">{data.items.length} 个</b>
|
|
126
|
+
</header>
|
|
127
|
+
)}
|
|
128
|
+
{!items.length ? <Empty text={data.noResultsReason ?? '暂未找到符合条件的优惠'} /> : (
|
|
129
|
+
<div ref={listRef} className={`promotion-list__items${expanded ? ' promotion-list__items--expanded' : ''}`} style={{ maxHeight: expanded ? listHeights.expanded || undefined : listHeights.collapsed || undefined }} onTransitionEnd={() => { if (bottomRef.current) scrollCardBottom(bottomRef.current); }}>
|
|
130
|
+
{items.map((item) => coupon ? (
|
|
131
|
+
<article
|
|
132
|
+
key={item.offerRef}
|
|
133
|
+
className="promotion-coupon-row"
|
|
134
|
+
role="button"
|
|
135
|
+
tabIndex={0}
|
|
136
|
+
aria-label={`${item.claimActionLabel ?? '领取'}「${item.title}」`}
|
|
137
|
+
onClick={() => setOpenRef(item.offerRef)}
|
|
138
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenRef(item.offerRef); } }}
|
|
139
|
+
>
|
|
140
|
+
<ImageWithFallback src={item.imageUrl} alt={item.title} className="promotion-coupon-row__thumb" />
|
|
141
|
+
<div className="promotion-coupon-row__main">
|
|
142
|
+
<strong className="promotion-coupon-row__title">{item.title}</strong>
|
|
143
|
+
{item.tags.some((tag) => !/^TOP[1-5]$/i.test(tag)) && <span className="promotion-coupon-row__tags">{item.tags.filter((tag) => !/^TOP[1-5]$/i.test(tag)).slice(0, 3).map((tag) => <em key={tag} className={/^(精选|餐饮|购物|生活)$/.test(tag) ? 'promotion-coupon-row__tag--category' : 'promotion-coupon-row__tag--meta'}>{tag}</em>)}</span>}
|
|
144
|
+
{item.subtitle && !couponSubtitleIsTime(item.subtitle)
|
|
145
|
+
&& <span className="promotion-coupon-row__sub" dangerouslySetInnerHTML={{ __html: item.subtitle }} />}
|
|
146
|
+
<span className="promotion-coupon-row__price">
|
|
147
|
+
{/* 售价缺省时退回 benefitText:老 provider 没有价格字段,不能让这行空着 */}
|
|
148
|
+
<b>{item.priceText ?? item.benefitText ?? offerTypeLabel(item.offerType)}</b>
|
|
149
|
+
{item.discountLabel && <em className="promotion-coupon-row__discount">{item.discountLabel}</em>}
|
|
150
|
+
{item.priceText && item.originalPriceText && <s>{item.originalPriceText}</s>}
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
{/* 领/抢是拉起浮层的入口,不是成交按钮:整行已是可交互控件,
|
|
154
|
+
这里只做视觉承诺——真正的领取动作只在浮层里出现一次 */}
|
|
155
|
+
<span className="promotion-item-actions promotion-item-actions--coupon" aria-hidden="true">
|
|
156
|
+
<span className="promotion-chip promotion-chip--coupon">{item.claimActionLabel ?? '领取'}</span>
|
|
157
|
+
</span>
|
|
158
|
+
</article>
|
|
159
|
+
) : (
|
|
160
|
+
<article key={item.offerRef}>
|
|
161
|
+
<span className="promotion-list__visual"><ImageWithFallback src={item.imageUrl} alt={item.title} className="promotion-list__thumb" /><em>{item.benefitText ?? offerTypeLabel(item.offerType)}</em></span>
|
|
162
|
+
<span className="promotion-list__copy">
|
|
163
|
+
<small>{promotion ? '本周活动' : offerTypeLabel(item.offerType)}</small>
|
|
164
|
+
<strong>{item.title}</strong>
|
|
165
|
+
<span>{item.subtitle ?? item.validityText ?? '当前商场可用'}</span>
|
|
166
|
+
</span>
|
|
167
|
+
<span className={`promotion-status promotion-status--${statusTone(item.availabilityStatus, item.claimable)}`}>{statusText(item.availabilityStatus, item.claimable)}</span>
|
|
168
|
+
<ItemActionsRow actions={item.itemActions ?? []} cardId={cardId} isHistory={isHistory} className="promotion-item-actions" chipClassName="promotion-chip" />
|
|
169
|
+
</article>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
{items.length > maxItems && (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className="promotion-list__toggle"
|
|
177
|
+
aria-expanded={expanded}
|
|
178
|
+
onClick={toggleExpanded}
|
|
179
|
+
>
|
|
180
|
+
{!coupon && <CaretDown size={16} data-rotated={expanded || undefined} />}
|
|
181
|
+
{expanded ? '收起' : `查看更多(${items.length - maxItems} 条)`}
|
|
182
|
+
{coupon && <CaretDown size={17} aria-hidden="true" data-rotated={expanded || undefined} />}
|
|
183
|
+
</button>
|
|
184
|
+
)}
|
|
185
|
+
{items.length > maxItems && <div ref={bottomRef} className="promotion-list__bottom-anchor" aria-hidden="true" />}
|
|
186
|
+
{data.nextCursor && <p className="promotion-list__more">还有更多内容,可继续查询</p>}
|
|
187
|
+
{openRef && (() => {
|
|
188
|
+
const target = items.find((i) => i.offerRef === openRef);
|
|
189
|
+
if (!target) return null;
|
|
190
|
+
const token = actionTokens?.['promotion.acquire-from-list'];
|
|
191
|
+
const label = target.claimActionLabel ?? '领取';
|
|
192
|
+
const purchaseOnly = label === '抢购';
|
|
193
|
+
const redirectOnly = purchaseOnly || target.freeCouponClaimMode === 'REDIRECT';
|
|
194
|
+
const redirectLabel = purchaseOnly ? '去小程序抢购' : '去小程序领取';
|
|
195
|
+
const facts = [
|
|
196
|
+
target.usageSceneText ? { icon: <MapPin weight="duotone" />, label: '使用场景', value: target.usageSceneText } : null,
|
|
197
|
+
target.usageTimeText ? { icon: <CalendarBlank weight="duotone" />, label: '使用时间', value: target.usageTimeText } : null,
|
|
198
|
+
target.thresholdText ? { icon: <Ticket weight="duotone" />, label: '使用限制', value: target.thresholdText } : null,
|
|
199
|
+
target.claimTimeText ? { icon: <CalendarBlank weight="duotone" />, label: '可领取时间', value: target.claimTimeText } : null,
|
|
200
|
+
target.validityText ? { icon: <CalendarBlank weight="duotone" />, label: '有效期', value: target.validityText } : null,
|
|
201
|
+
target.usageRuleText ? { icon: <Ticket weight="duotone" />, label: '使用规则', value: target.usageRuleText } : null,
|
|
202
|
+
].filter(Boolean) as SheetFact[];
|
|
203
|
+
const priceMain = target.priceText ?? target.benefitText ?? offerTypeLabel(target.offerType);
|
|
204
|
+
return (
|
|
205
|
+
<CouponSheet
|
|
206
|
+
title={target.title}
|
|
207
|
+
imageUrl={target.imageUrl}
|
|
208
|
+
noticeText={target.usageNoticeText}
|
|
209
|
+
guaranteeText={target.guaranteeText}
|
|
210
|
+
subtitle={redirectOnly
|
|
211
|
+
? [target.subtitle, target.purchaseUrl
|
|
212
|
+
? `该优惠需前往小程序完成${purchaseOnly ? '购买' : '领取'}`
|
|
213
|
+
: `该优惠的${purchaseOnly ? '抢购' : '领取'}链接暂不可用`].filter(Boolean).join(' · ')
|
|
214
|
+
: target.subtitle}
|
|
215
|
+
priceMain={priceMain}
|
|
216
|
+
priceOriginal={target.priceText ? target.originalPriceText : undefined}
|
|
217
|
+
aside={target.stockText}
|
|
218
|
+
facts={facts}
|
|
219
|
+
onClose={() => {
|
|
220
|
+
setOpenRef(null);
|
|
221
|
+
setClaimingRef((current) => current === target.offerRef ? null : current);
|
|
222
|
+
setClaimedRefs((current) => {
|
|
223
|
+
if (!current.has(target.offerRef)) return current;
|
|
224
|
+
const next = new Set(current);
|
|
225
|
+
next.delete(target.offerRef);
|
|
226
|
+
return next;
|
|
227
|
+
});
|
|
228
|
+
}}
|
|
229
|
+
foot={<>
|
|
230
|
+
<span className="promotion-sheet__foot-price">
|
|
231
|
+
<b>{target.priceText ?? target.benefitText ?? ''}</b>
|
|
232
|
+
{target.priceText && target.originalPriceText && <s>{target.originalPriceText}</s>}
|
|
233
|
+
</span>
|
|
234
|
+
{redirectOnly ? (
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
disabled={!target.purchaseUrl}
|
|
238
|
+
className="promotion-chip promotion-chip--coupon promotion-chip--coupon-lg"
|
|
239
|
+
onClick={() => {
|
|
240
|
+
if (!target.purchaseUrl) return;
|
|
241
|
+
void navigateToAlipayPage(target.purchaseUrl).catch((err: unknown) => {
|
|
242
|
+
console.warn('[PromotionOfferListCard] navigateTo failed', err);
|
|
243
|
+
});
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{target.purchaseUrl ? redirectLabel : `${purchaseOnly ? '抢购' : '领取'}链接暂不可用`}
|
|
247
|
+
</button>
|
|
248
|
+
) : token && !isHistory ? (
|
|
249
|
+
// 直接走写动作,不再绕发消息:结果以新卡片回到对话流,当前浮层保持已领取态
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
className="promotion-chip promotion-chip--coupon promotion-chip--coupon-lg"
|
|
253
|
+
onClick={() => {
|
|
254
|
+
setClaimingRef(target.offerRef);
|
|
255
|
+
sendCardAction(cardId, { type: 'moduleAction', actionId: 'promotion.acquire-from-list', contextToken: token }, { offerRef: target.offerRef });
|
|
256
|
+
}}
|
|
257
|
+
disabled={claimingRef === target.offerRef || claimedRefs.has(target.offerRef)}
|
|
258
|
+
>
|
|
259
|
+
{claimedRefs.has(target.offerRef) ? '已领取' : claimingRef === target.offerRef ? '领取中,请稍候…' : label}
|
|
260
|
+
</button>
|
|
261
|
+
) : (
|
|
262
|
+
// 无令牌(历史卡、旧服务端)时退回发消息,保住可用性
|
|
263
|
+
<ItemActionsRow
|
|
264
|
+
actions={target.itemActions ?? []}
|
|
265
|
+
cardId={cardId}
|
|
266
|
+
isHistory={isHistory}
|
|
267
|
+
className="promotion-item-actions"
|
|
268
|
+
chipClassName="promotion-chip promotion-chip--coupon promotion-chip--coupon-lg"
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
271
|
+
</>}
|
|
272
|
+
/>
|
|
273
|
+
);
|
|
274
|
+
})()}
|
|
275
|
+
</section>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
markSelfRendering(PromotionOfferListCard);
|
|
280
|
+
|
|
281
|
+
function Empty({ text }: { text: string }) {
|
|
282
|
+
return <div className="promotion-card__empty"><Ticket size={28} weight="duotone" /><span>{text}</span></div>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export default PromotionOfferListCard;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promotion 展示纯函数(0.4.0 D6:状态文案/色调映射,可单测)。
|
|
3
|
+
*/
|
|
4
|
+
export type PromotionTone = 'available' | 'unavailable' | 'succeeded' | 'pending' | 'rejected' | 'neutral';
|
|
5
|
+
|
|
6
|
+
export function offerTypeLabel(offerType: string): string {
|
|
7
|
+
switch (offerType) {
|
|
8
|
+
case 'COUPON':
|
|
9
|
+
return '优惠券';
|
|
10
|
+
case 'BUNDLE':
|
|
11
|
+
return '组合券';
|
|
12
|
+
case 'PROMOTION':
|
|
13
|
+
return '促销';
|
|
14
|
+
case 'ACTIVITY':
|
|
15
|
+
return '活动';
|
|
16
|
+
case 'GROUPON':
|
|
17
|
+
return '团购';
|
|
18
|
+
default:
|
|
19
|
+
return '优惠';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function entitlementStatusLabel(status: string): string {
|
|
24
|
+
switch (status) {
|
|
25
|
+
case 'EFFECTIVE':
|
|
26
|
+
return '可用';
|
|
27
|
+
case 'USED':
|
|
28
|
+
return '已使用';
|
|
29
|
+
case 'EXPIRED':
|
|
30
|
+
return '已过期';
|
|
31
|
+
case 'INVALID':
|
|
32
|
+
return '已失效';
|
|
33
|
+
default:
|
|
34
|
+
return '未知';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function entitlementTone(status: string): 'available' | 'unavailable' | 'neutral' {
|
|
39
|
+
if (status === 'EFFECTIVE') return 'available';
|
|
40
|
+
if (status === 'USED' || status === 'EXPIRED' || status === 'INVALID') return 'unavailable';
|
|
41
|
+
return 'neutral';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resultTone(status: string): 'succeeded' | 'pending' | 'rejected' {
|
|
45
|
+
if (status === 'SUCCEEDED') return 'succeeded';
|
|
46
|
+
if (status === 'PENDING_AUTHORITY') return 'pending';
|
|
47
|
+
return 'rejected';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resultStatusText(status: string): string {
|
|
51
|
+
if (status === 'SUCCEEDED') return '成功';
|
|
52
|
+
if (status === 'PENDING_AUTHORITY') return '确认中';
|
|
53
|
+
return '失败';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sessionTone(status: string): 'available' | 'unavailable' | 'neutral' {
|
|
57
|
+
if (status === 'OPEN') return 'available';
|
|
58
|
+
if (status === 'FULL' || status === 'CLOSED') return 'unavailable';
|
|
59
|
+
return 'neutral';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sessionStatusText(status: string): string {
|
|
63
|
+
if (status === 'OPEN') return '可报名';
|
|
64
|
+
if (status === 'FULL') return '已满';
|
|
65
|
+
if (status === 'CLOSED') return '已结束';
|
|
66
|
+
return '未知';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 条目/活动可用性文案(offerSummary claimable 语义) */
|
|
70
|
+
export function availabilityText(claimable: boolean): string {
|
|
71
|
+
return claimable ? '可领取' : '不可领';
|
|
72
|
+
}
|