@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,48 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
2
|
+
import JsBarcode from 'jsbarcode';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useRef } from 'react';
|
|
5
|
+
|
|
6
|
+
export function CouponQrCode(props: { value: string; disabled?: boolean }) {
|
|
7
|
+
const { value, disabled = false } = props;
|
|
8
|
+
const [src, setSrc] = useState<string>();
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let active = true;
|
|
12
|
+
setSrc(undefined);
|
|
13
|
+
void QRCode.toDataURL(value, {
|
|
14
|
+
width: 200,
|
|
15
|
+
margin: 1,
|
|
16
|
+
errorCorrectionLevel: 'M',
|
|
17
|
+
color: { dark: disabled ? '#999999' : '#000000', light: '#ffffff' },
|
|
18
|
+
}).then((dataUrl) => {
|
|
19
|
+
if (active) setSrc(dataUrl);
|
|
20
|
+
}).catch(() => {
|
|
21
|
+
if (active) setSrc(undefined);
|
|
22
|
+
});
|
|
23
|
+
return () => { active = false; };
|
|
24
|
+
}, [disabled, value]);
|
|
25
|
+
|
|
26
|
+
return src ? <img className={`promotion-sheet__qrcode${disabled ? ' promotion-sheet__qrcode--disabled' : ''}`} src={src} alt="券码二维码" /> : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function CouponBarcode(props: { value: string; disabled?: boolean }) {
|
|
30
|
+
const { value, disabled = false } = props;
|
|
31
|
+
const ref = useRef<SVGSVGElement>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!ref.current) return;
|
|
34
|
+
try {
|
|
35
|
+
JsBarcode(ref.current, value, {
|
|
36
|
+
format: 'CODE128',
|
|
37
|
+
width: 2,
|
|
38
|
+
height: 52,
|
|
39
|
+
margin: 0,
|
|
40
|
+
displayValue: false,
|
|
41
|
+
lineColor: disabled ? '#999999' : '#000000',
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
ref.current.replaceChildren();
|
|
45
|
+
}
|
|
46
|
+
}, [disabled, value]);
|
|
47
|
+
return <svg ref={ref} className={`promotion-sheet__barcode${disabled ? ' promotion-sheet__barcode--disabled' : ''}`} aria-label="券码条形码" role="img" />;
|
|
48
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 券浮层:从券行就地拉起的底部抽屉,券列表与「我的券」共用。
|
|
3
|
+
*
|
|
4
|
+
* 它替代了原来的券详情卡——就地展开、没有二次请求可补,所以呈现什么完全取决于
|
|
5
|
+
* 列表这一跳已经下发的字段:**缺的整块不渲染,绝不留空壳,也不编造占位文案**。
|
|
6
|
+
*/
|
|
7
|
+
import { X } from '@phosphor-icons/react';
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
import { ImageWithFallback } from './ImageWithFallback';
|
|
11
|
+
import { sanitizeActivityHtml } from '../utils/sanitize-activity-html';
|
|
12
|
+
|
|
13
|
+
export interface SheetFact {
|
|
14
|
+
readonly icon: React.ReactNode;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly value: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CouponSheet(props: {
|
|
20
|
+
title: string;
|
|
21
|
+
imageUrl?: string;
|
|
22
|
+
subtitle?: string;
|
|
23
|
+
noticeText?: string;
|
|
24
|
+
guaranteeText?: string;
|
|
25
|
+
/** 图下沿的压条主文案:券列表放售价,我的券放权益额度 */
|
|
26
|
+
priceMain?: string;
|
|
27
|
+
/** 划线原价,仅在有主售价时才有比较意义 */
|
|
28
|
+
priceOriginal?: string;
|
|
29
|
+
/** 压条右侧:库存或状态 */
|
|
30
|
+
aside?: string;
|
|
31
|
+
facts?: readonly SheetFact[];
|
|
32
|
+
/** facts 之前的扩展内容,例如已领取券的券码和适用商家。 */
|
|
33
|
+
beforeFacts?: React.ReactNode;
|
|
34
|
+
extra?: React.ReactNode;
|
|
35
|
+
/** 底部贴边条;无动作可给的场景整条不出 */
|
|
36
|
+
foot?: React.ReactNode;
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
}) {
|
|
39
|
+
const { title, imageUrl, subtitle, noticeText, guaranteeText, priceMain, priceOriginal, aside, facts = [], beforeFacts, extra, foot, onClose } = props;
|
|
40
|
+
const [closing, setClosing] = useState(false);
|
|
41
|
+
const closingRef = useRef(false);
|
|
42
|
+
const closeTimerRef = useRef<number>();
|
|
43
|
+
const onCloseRef = useRef(onClose);
|
|
44
|
+
onCloseRef.current = onClose;
|
|
45
|
+
|
|
46
|
+
const finishClose = useCallback(() => {
|
|
47
|
+
if (!closingRef.current) return;
|
|
48
|
+
window.clearTimeout(closeTimerRef.current);
|
|
49
|
+
closingRef.current = false;
|
|
50
|
+
setClosing(false);
|
|
51
|
+
onCloseRef.current();
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const requestClose = useCallback(() => {
|
|
55
|
+
if (closingRef.current) return;
|
|
56
|
+
closingRef.current = true;
|
|
57
|
+
setClosing(true);
|
|
58
|
+
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false;
|
|
59
|
+
closeTimerRef.current = window.setTimeout(finishClose, reducedMotion ? 1 : 200);
|
|
60
|
+
}, [finishClose]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') requestClose(); };
|
|
64
|
+
document.addEventListener('keydown', onKey);
|
|
65
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
66
|
+
}, [requestClose]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
|
|
69
|
+
|
|
70
|
+
return createPortal(
|
|
71
|
+
<div
|
|
72
|
+
className={`promotion-sheet${closing ? ' promotion-sheet--closing' : ''}`}
|
|
73
|
+
role="dialog"
|
|
74
|
+
aria-modal="true"
|
|
75
|
+
aria-label={`${title} 详情`}
|
|
76
|
+
onAnimationEnd={(event) => {
|
|
77
|
+
if (closing && event.target === event.currentTarget) finishClose();
|
|
78
|
+
}}
|
|
79
|
+
onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}
|
|
80
|
+
>
|
|
81
|
+
<div className="promotion-sheet__panel">
|
|
82
|
+
<button type="button" className="promotion-sheet__close" aria-label="关闭详情" onClick={requestClose}>
|
|
83
|
+
<X size={18} weight="bold" aria-hidden="true" />
|
|
84
|
+
</button>
|
|
85
|
+
<div className="promotion-sheet__scroll">
|
|
86
|
+
<ImageWithFallback src={imageUrl} alt={title} className="promotion-sheet__image" />
|
|
87
|
+
{/* 价格条压在图下沿:与列表行同一组价格语言,浮层里放大一档 */}
|
|
88
|
+
{(priceMain || aside) && (
|
|
89
|
+
<div className="promotion-sheet__price">
|
|
90
|
+
{priceMain && <b>{priceMain}</b>}
|
|
91
|
+
{priceMain && priceOriginal && <s>{priceOriginal}</s>}
|
|
92
|
+
{aside && <span className="promotion-sheet__stock">{aside}</span>}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
<h4 className="promotion-sheet__title">{title}</h4>
|
|
96
|
+
{(noticeText || guaranteeText) && <div className="promotion-sheet__notice-strip">
|
|
97
|
+
{noticeText && <span><b>须知</b>{noticeText}</span>}
|
|
98
|
+
{guaranteeText && <span><b>保障</b>{guaranteeText}</span>}
|
|
99
|
+
</div>}
|
|
100
|
+
{subtitle && <p className="promotion-sheet__sub" dangerouslySetInnerHTML={{ __html: subtitle }} />}
|
|
101
|
+
{beforeFacts}
|
|
102
|
+
{facts.length > 0 && (
|
|
103
|
+
<>
|
|
104
|
+
<div className="promotion-sheet__section">使用须知</div>
|
|
105
|
+
<dl className="promotion-sheet__facts">
|
|
106
|
+
{facts.map((f) => (
|
|
107
|
+
<div key={f.label}>
|
|
108
|
+
<dt>{f.icon}{f.label}</dt>
|
|
109
|
+
<dd dangerouslySetInnerHTML={{ __html: sanitizeActivityHtml(f.value) }} />
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
</dl>
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
{extra}
|
|
116
|
+
</div>
|
|
117
|
+
{foot && <div className="promotion-sheet__foot">{foot}</div>}
|
|
118
|
+
</div>
|
|
119
|
+
</div>,
|
|
120
|
+
document.body,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default CouponSheet;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图片 + 加载失败占位(0.4.0 D6:模块自持 ~20 行,禁跨模块依赖/基元封顶不破)。
|
|
3
|
+
* 源:POC shopping-guide 占位语义。src 缺省或加载失败 → 图标占位。
|
|
4
|
+
*/
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { ImageSquare } from '@phosphor-icons/react';
|
|
7
|
+
|
|
8
|
+
export function ImageWithFallback(props: {
|
|
9
|
+
src?: string;
|
|
10
|
+
alt: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
/** 占位区尺寸(px,图标随比例缩小) */
|
|
13
|
+
size?: number;
|
|
14
|
+
}) {
|
|
15
|
+
const { src, alt, className, size = 40 } = props;
|
|
16
|
+
const [failed, setFailed] = useState(false);
|
|
17
|
+
if (!src || failed) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={`img-fallback promotion-image--fallback ${className ?? ''}`.trim()}
|
|
21
|
+
aria-label={alt}
|
|
22
|
+
role="img"
|
|
23
|
+
>
|
|
24
|
+
<ImageSquare weight="duotone" size={Math.round(size * 0.45)} />
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
<img
|
|
30
|
+
className={className}
|
|
31
|
+
src={src}
|
|
32
|
+
alt={alt}
|
|
33
|
+
loading="eager"
|
|
34
|
+
decoding="async"
|
|
35
|
+
referrerPolicy="no-referrer"
|
|
36
|
+
onError={() => setFailed(true)}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* module-promotion 客户端入口:9 个稳定传输卡型,renderer 内覆盖多个展示语义。
|
|
3
|
+
*/
|
|
4
|
+
import './styles/promotion.css'; // 0.4.0 D1:模块自持 CSS(副作用 import,首行保证先行加载)
|
|
5
|
+
|
|
1
6
|
import type { CardComponent } from '@tbox.cn/app-contracts';
|
|
2
7
|
import type { ClientContext, ClientModule } from '@tbox.cn/app-sdk/client';
|
|
3
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
PromotionOfferListCard,
|
|
10
|
+
PromotionOfferDetailCard,
|
|
11
|
+
PromotionEntitlementListCard,
|
|
12
|
+
PromotionActivityListCard,
|
|
13
|
+
PromotionActivityDetailCard,
|
|
14
|
+
PromotionActivityCategoryListCard,
|
|
15
|
+
PromotionBestDealCard,
|
|
16
|
+
} from './cards';
|
|
4
17
|
|
|
5
|
-
/** 客户端卡片组件 map(key 约定 = cardType;CLI sync 装配读取) */
|
|
6
18
|
const cards = {
|
|
7
|
-
|
|
19
|
+
'promotion-offer-list': PromotionOfferListCard as CardComponent,
|
|
20
|
+
'promotion-offer-detail': PromotionOfferDetailCard as CardComponent,
|
|
21
|
+
'promotion-entitlement-list': PromotionEntitlementListCard as CardComponent,
|
|
22
|
+
'promotion-activity-list': PromotionActivityListCard as CardComponent,
|
|
23
|
+
'promotion-activity-detail': PromotionActivityDetailCard as CardComponent,
|
|
24
|
+
'promotion-activity-category-list': PromotionActivityCategoryListCard as CardComponent,
|
|
25
|
+
'promotion-best-deal': PromotionBestDealCard as CardComponent,
|
|
8
26
|
} as const;
|
|
9
27
|
|
|
10
|
-
/** 模块注册入口:自注册闭环(与服务端 serverModule 对称) */
|
|
11
28
|
export const clientModule = {
|
|
12
29
|
cards,
|
|
13
30
|
register(ctx: ClientContext): void {
|
|
14
31
|
ctx.cards.registerMap(cards);
|
|
15
32
|
},
|
|
16
33
|
} satisfies ClientModule;
|
|
34
|
+
|
|
35
|
+
export { cards as promotionClientCards };
|