@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
|
+
/**
|
|
2
|
+
* sanitizeActivityHtml — 活动规则富文本过滤(零依赖,DOM API)。
|
|
3
|
+
*
|
|
4
|
+
* 数据源是支付宝开放平台 `activityRuleDetail`(半信任:来自外部 API,非用户输入直通)。
|
|
5
|
+
* 卡片经 dangerouslySetInnerHTML 渲染该 HTML——渲染前剥离全部脚本面:
|
|
6
|
+
* - 丢弃元素:script/iframe/object/embed/style/link/meta/base/form/input + svg/math
|
|
7
|
+
* (svg/math 是 XSS 载体高发区且活动文案用不到——直接整体丢弃,攻击面收敛优于逐属性过滤);
|
|
8
|
+
* - 丢弃事件属性(onerror 等,on* 前缀全清);
|
|
9
|
+
* - URL 属性白名单值过滤:href/src/xlink:href/formaction/action/poster/background 命中
|
|
10
|
+
* javascript: 或非 image data: 协议即剥离(HTML entity 解码由 DOMParser 先行完成,j 混淆无效);
|
|
11
|
+
* - 保留其余展示属性与内联 style(现代浏览器 CSS url(javascript:) 不执行——活动文案排版依赖内联样式)。
|
|
12
|
+
*/
|
|
13
|
+
const DROP_TAGS = new Set([
|
|
14
|
+
'script', 'iframe', 'object', 'embed', 'style', 'link', 'meta', 'base', 'form', 'input',
|
|
15
|
+
'svg', 'math',
|
|
16
|
+
]);
|
|
17
|
+
/** 可承载 URL 的属性全集(含 SVG xlink 与 form 提交面)——值命中危险协议即剥离。 */
|
|
18
|
+
const URL_ATTRS = new Set(['href', 'src', 'xlink:href', 'formaction', 'action', 'poster', 'background']);
|
|
19
|
+
|
|
20
|
+
export function sanitizeActivityHtml(html: string): string {
|
|
21
|
+
if (typeof DOMParser === 'undefined') return '';
|
|
22
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
23
|
+
const walk = (node: Element): void => {
|
|
24
|
+
for (const child of [...node.children]) {
|
|
25
|
+
// tagName 大小写在 HTML 解析器间不稳定(浏览器大写 SVG、happy-dom 小写)——统一小写比较
|
|
26
|
+
if (DROP_TAGS.has(child.tagName.toLowerCase())) {
|
|
27
|
+
child.remove();
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
for (const attr of [...child.attributes]) {
|
|
31
|
+
const name = attr.name.toLowerCase();
|
|
32
|
+
if (name.startsWith('on')) {
|
|
33
|
+
child.removeAttribute(attr.name);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (URL_ATTRS.has(name)) {
|
|
37
|
+
const value = attr.value.trim().toLowerCase();
|
|
38
|
+
const isJs = value.startsWith('javascript:');
|
|
39
|
+
const isData = value.startsWith('data:') && !value.startsWith('data:image/');
|
|
40
|
+
if (isJs || isData) child.removeAttribute(attr.name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
walk(child);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(doc.body);
|
|
47
|
+
return doc.body.innerHTML;
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const MIN_BOTTOM_GAP = 96;
|
|
2
|
+
|
|
3
|
+
/** 将卡片底部放在宿主固定底部操作区之上,且展开/收起都使用同一滚动基准。 */
|
|
4
|
+
export function scrollCardBottom(anchor: HTMLElement): void {
|
|
5
|
+
const rootStyle = typeof document !== 'undefined' ? getComputedStyle(document.documentElement) : undefined;
|
|
6
|
+
const reservedHeight = Number.parseFloat(rootStyle?.getPropertyValue('--tbox-chat-bottom-height') ?? '') || 0;
|
|
7
|
+
const bottomGap = Math.max(MIN_BOTTOM_GAP, reservedHeight + 24);
|
|
8
|
+
let scroller = anchor.parentElement;
|
|
9
|
+
while (scroller && scroller !== document.body) {
|
|
10
|
+
const style = getComputedStyle(scroller);
|
|
11
|
+
if (/(auto|scroll|overlay)/.test(style.overflowY) && scroller.scrollHeight > scroller.clientHeight) break;
|
|
12
|
+
scroller = scroller.parentElement;
|
|
13
|
+
}
|
|
14
|
+
if (!scroller || scroller === document.body) {
|
|
15
|
+
anchor.scrollIntoView?.({ behavior: 'smooth', block: 'end' });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
19
|
+
const scrollerRect = scroller.getBoundingClientRect();
|
|
20
|
+
scroller.scrollTo?.({
|
|
21
|
+
top: scroller.scrollTop + anchorRect.bottom - scrollerRect.bottom + bottomGap,
|
|
22
|
+
behavior: 'smooth',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 在列表仍保持展开时回滚隐藏条目的高度,避免内容收缩触发 scrollTop 瞬间截断。 */
|
|
27
|
+
export function scrollCardBy(anchor: HTMLElement, delta: number): void {
|
|
28
|
+
if (!delta) return;
|
|
29
|
+
let scroller = anchor.parentElement;
|
|
30
|
+
while (scroller && scroller !== document.body) {
|
|
31
|
+
const style = getComputedStyle(scroller);
|
|
32
|
+
if (/(auto|scroll|overlay)/.test(style.overflowY) && scroller.scrollHeight > scroller.clientHeight) break;
|
|
33
|
+
scroller = scroller.parentElement;
|
|
34
|
+
}
|
|
35
|
+
if (!scroller || scroller === document.body) {
|
|
36
|
+
anchor.scrollIntoView?.({ behavior: 'smooth', block: 'end' });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
scroller.scrollTo?.({ top: scroller.scrollTop + delta, behavior: 'smooth' });
|
|
40
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promotion 写动作(015 S6:唯一写动作 acquire;execute 内预检 R3 + member.status 会员门禁)。
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import type { ServerContext } from '@tbox.cn/app-sdk/server';
|
|
6
|
+
import type { CardActionResult } from '@tbox.cn/app-contracts';
|
|
7
|
+
import { memberGateCardOf, externalMemberRefOf, toMallQueryContext } from '@tbox.cn/app-contracts-mall';
|
|
8
|
+
import type {
|
|
9
|
+
MemberGateCard,
|
|
10
|
+
MemberOutcome,
|
|
11
|
+
MemberStatusFactory,
|
|
12
|
+
PromotionEntitlementPort,
|
|
13
|
+
PromotionQueryContext,
|
|
14
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { resolvePromotionAcquisitionPort, resolvePromotionEntitlementsPort } from './ports';
|
|
17
|
+
import { entitlementSummary } from './tools';
|
|
18
|
+
import type { PromotionRejectionKind } from '@tbox.cn/app-contracts-mall';
|
|
19
|
+
|
|
20
|
+
async function listEntitlementsAfterAcquire(
|
|
21
|
+
service: PromotionEntitlementPort,
|
|
22
|
+
q: PromotionQueryContext,
|
|
23
|
+
) {
|
|
24
|
+
// 厂商领取成功与券包可读存在短暂最终一致性窗口;有限重试后仍退回成功文案,
|
|
25
|
+
// 不把领取结果错误判定为失败,也避免无限等待动作请求。
|
|
26
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
27
|
+
const page = await service.listEntitlements(q, { limit: 10 });
|
|
28
|
+
if (page.items.length > 0 || attempt === 2) return page;
|
|
29
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 150));
|
|
30
|
+
}
|
|
31
|
+
return { items: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 已知厂商码的语义兜底;上游提供 message 时不覆盖原文。 */
|
|
35
|
+
export function classifyRejection(code?: string): { kind: PromotionRejectionKind; message: string } {
|
|
36
|
+
switch (code) {
|
|
37
|
+
case '30013':
|
|
38
|
+
case '30011':
|
|
39
|
+
return { kind: 'AUTH_FAILED', message: '会员登录已过期,请重新登录会员后再领取' };
|
|
40
|
+
case '636':
|
|
41
|
+
case '637':
|
|
42
|
+
return { kind: 'OUT_OF_STOCK', message: '券已被领完,可关注下次放量' };
|
|
43
|
+
case '638':
|
|
44
|
+
return { kind: 'ALREADY_ACQUIRED', message: '已领取过该券,可在「我的券」中查看' };
|
|
45
|
+
case '5113':
|
|
46
|
+
return { kind: 'ALREADY_ACQUIRED', message: '领取次数已达到上限' };
|
|
47
|
+
case '30017':
|
|
48
|
+
return { kind: 'NOT_ELIGIBLE', message: '不满足领取条件(如仅限特定等级会员)' };
|
|
49
|
+
default:
|
|
50
|
+
return { kind: 'UNKNOWN', message: '领取失败,请稍后重试' };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 卡片动作 execute 第二参(RequestContext)——门禁函数与 executeAcquire 共用 */
|
|
55
|
+
type AcquireReqCtx = Parameters<NonNullable<Parameters<ServerContext['cardActions']['register']>[0]['execute']>>[1];
|
|
56
|
+
|
|
57
|
+
/** acquire 会员门禁文案(active 放行不查表;表只建模拦截态) */
|
|
58
|
+
const ACQUIRE_MEMBER_NOTICE = '领取需要会员身份,请先登录或办理入会后再领券';
|
|
59
|
+
const ACQUIRE_ENROLL_NOTICE = '领取需要会员身份,办理入会后即可领券';
|
|
60
|
+
const ACQUIRE_UNKNOWN_NOTICE = '暂时无法确认会员状态,请稍后再试';
|
|
61
|
+
const ACQUIRE_GATE_COPY: Record<Exclude<MemberOutcome | 'unavailable', 'active'>, string> = {
|
|
62
|
+
unauthenticated: ACQUIRE_MEMBER_NOTICE,
|
|
63
|
+
not_enrolled: ACQUIRE_ENROLL_NOTICE,
|
|
64
|
+
closed: ACQUIRE_ENROLL_NOTICE,
|
|
65
|
+
unknown: ACQUIRE_UNKNOWN_NOTICE,
|
|
66
|
+
unavailable: ACQUIRE_UNKNOWN_NOTICE,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function createPromotionActions(ctx: ServerContext): Record<string, Parameters<ServerContext['cardActions']['register']>[0]> {
|
|
70
|
+
type AcquireGate = { readonly blocked: string; readonly card?: MemberGateCard } | { readonly externalMemberRef: string };
|
|
71
|
+
|
|
72
|
+
/** 会员门禁:active 放行并解析厂商侧会员标识;其余态收口为引导文案。
|
|
73
|
+
* 软语义:unknown/unavailable 不断言「需要会员身份」——不误伤可能是会员的用户。 */
|
|
74
|
+
const resolveAcquireGate = async (reqCtx: AcquireReqCtx): Promise<AcquireGate> => {
|
|
75
|
+
const factory = ctx.services?.resolve?.('member.status') as MemberStatusFactory | undefined;
|
|
76
|
+
if (!factory) return { blocked: ACQUIRE_MEMBER_NOTICE }; // member 未装/旧版:版本偏斜兼容档
|
|
77
|
+
const status = await factory(reqCtx);
|
|
78
|
+
if (status.outcome !== 'active') {
|
|
79
|
+
// blocked 附卡:member-enrollment-cta / member-login-cta 由 TIER1 纯映射生成;
|
|
80
|
+
// 文案保留模块自有 ACQUIRE_GATE_COPY(写路径 fail-closed 语义,unknown/unavailable 也拦)
|
|
81
|
+
const card = memberGateCardOf(reqCtx?.identity?.userId ?? '', status);
|
|
82
|
+
return card ? { blocked: ACQUIRE_GATE_COPY[status.outcome], card } : { blocked: ACQUIRE_GATE_COPY[status.outcome] };
|
|
83
|
+
}
|
|
84
|
+
if (!status.account) {
|
|
85
|
+
ctx.logger?.warn('promotion.acquire:member.status active 缺账户快照(解析器原子性破坏)');
|
|
86
|
+
return { blocked: ACQUIRE_UNKNOWN_NOTICE };
|
|
87
|
+
}
|
|
88
|
+
const externalMemberRef = externalMemberRefOf(status.account) ?? '';
|
|
89
|
+
if (!externalMemberRef) {
|
|
90
|
+
ctx.logger?.warn('promotion.acquire:active 会员账户无厂商侧引用(真实卡号与 opaque 引用双缺)');
|
|
91
|
+
return { blocked: ACQUIRE_UNKNOWN_NOTICE };
|
|
92
|
+
}
|
|
93
|
+
return { externalMemberRef };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const executeAcquire = async (input: unknown, reqCtx: Parameters<NonNullable<Parameters<ServerContext['cardActions']['register']>[0]['execute']>>[1]): Promise<CardActionResult> => {
|
|
97
|
+
const { offerRef } = (input ?? {}) as { offerRef?: string };
|
|
98
|
+
const userId = reqCtx?.identity?.userId ?? '';
|
|
99
|
+
if (!offerRef || !userId || !reqCtx) {
|
|
100
|
+
// 失败态只回文字:SDK 在结果无卡时把 text 经 tbox:notice 下发为可见提示,
|
|
101
|
+
// 一句话说清原因即可,不值得为此再出一张卡
|
|
102
|
+
return { text: '领取失败:缺少优惠引用或身份' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 会员门禁(在幂等占位之前——门禁失败不产生占位):member.status 判定会员态,
|
|
106
|
+
// active 经 externalMemberRefOf 取厂商侧会员标识(真实卡号优先)
|
|
107
|
+
const gate = await resolveAcquireGate(reqCtx);
|
|
108
|
+
if ('blocked' in gate) {
|
|
109
|
+
return gate.card
|
|
110
|
+
? { card: { cardType: gate.card.cardType, data: gate.card.data }, text: gate.blocked }
|
|
111
|
+
: { text: gate.blocked };
|
|
112
|
+
}
|
|
113
|
+
const externalMemberRef = gate.externalMemberRef;
|
|
114
|
+
|
|
115
|
+
const q = toMallQueryContext(reqCtx);
|
|
116
|
+
// 双槽动作(017):领取写 → promotion.acquisition;领后「我的券」列表 → promotion.entitlements
|
|
117
|
+
const service = await resolvePromotionAcquisitionPort(reqCtx, ctx);
|
|
118
|
+
// 每次用户明确点击都提交给 Provider;是否允许重复领取由厂商返回码决定。
|
|
119
|
+
// 仍使用随机幂等键,避免不同尝试共享上一次请求的 extend/operation 引用。
|
|
120
|
+
const idempotencyKey = randomUUID();
|
|
121
|
+
|
|
122
|
+
let operation: Awaited<ReturnType<typeof service.acquire>>;
|
|
123
|
+
try {
|
|
124
|
+
operation = await service.acquire(q, { offerRef, externalMemberRef, idempotencyKey });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// Code security: 不把第三方异常消息/响应原文写入线上日志,只保留可关联的类型与短键。
|
|
127
|
+
const errorType = e instanceof Error ? e.name : 'UnknownError';
|
|
128
|
+
ctx.logger?.warn(`领券请求异常 key=${idempotencyKey.slice(0, 8)}…:type=${errorType}`);
|
|
129
|
+
return { text: '领取请求失败,请稍后重试' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (operation.status === 'SUCCEEDED') {
|
|
133
|
+
// 领到手就把「我的券」推到面前:比一张只说"成功了"的结果卡更有用——
|
|
134
|
+
// 刚领的券连同券码、有效期一起可见,落点也正是用户接下来要去的地方。
|
|
135
|
+
// 列表查询失败不影响领取已成功这件事,退回文字提示即可。
|
|
136
|
+
try {
|
|
137
|
+
const entitlements = await resolvePromotionEntitlementsPort(reqCtx, ctx);
|
|
138
|
+
const page = await listEntitlementsAfterAcquire(entitlements, q);
|
|
139
|
+
if (page.items.length) {
|
|
140
|
+
return {
|
|
141
|
+
card: {
|
|
142
|
+
cardType: 'promotion-entitlement-list',
|
|
143
|
+
data: { mallId: q.scope.mallId, miniAppId: q.scope.miniAppId, items: page.items.map(entitlementSummary) },
|
|
144
|
+
},
|
|
145
|
+
text: '领取成功!已存入卡包',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
ctx.logger?.warn(`领券成功但我的券列表查询失败:err=${(e as Error).message}`);
|
|
150
|
+
}
|
|
151
|
+
return { text: '领取成功!已存入卡包,可在「我的券」中查看' };
|
|
152
|
+
}
|
|
153
|
+
if (operation.status === 'PENDING_AUTHORITY') {
|
|
154
|
+
return { text: '领取已受理,正在确认结果,稍后可在「我的券」查看' };
|
|
155
|
+
}
|
|
156
|
+
const providerMessage = operation.message?.trim();
|
|
157
|
+
ctx.logger?.warn(`领券被厂商拒绝 code=${operation.code ?? 'unknown'} operation=${operation.operationRef.slice(0, 12)} message=${logText(providerMessage)}`);
|
|
158
|
+
// Code security: Provider business message is user-facing context; use the bounded SDK notice path.
|
|
159
|
+
// Keep the source-compatible semantic fallback only when the upstream response has no usable message;
|
|
160
|
+
// the raw provider code remains visible in either case.
|
|
161
|
+
const reason = providerMessage || classifyRejection(operation.code).message;
|
|
162
|
+
return { text: `领取失败:${reason}${operation.code ? `(code ${operation.code})` : ''}` };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
acquire: {
|
|
167
|
+
actionId: 'promotion.acquire',
|
|
168
|
+
moduleId: 'promotion',
|
|
169
|
+
cardType: 'promotion-offer-detail',
|
|
170
|
+
label: '立即领取',
|
|
171
|
+
singleUse: true,
|
|
172
|
+
inputSchema: z.object({ offerRef: z.string().min(1) }),
|
|
173
|
+
execute: executeAcquire,
|
|
174
|
+
},
|
|
175
|
+
// 列表内浮层的领取入口:一张列表卡对应 N 张券,令牌必须可重复使用——
|
|
176
|
+
// singleUse 按 grantId 一次性消费,领完第一张后同卡其余券会全部撞 TOKEN_CONSUMED。
|
|
177
|
+
// 重复领同一张券由 provider 侧幂等兜底(已领会回一句文字提示)。
|
|
178
|
+
acquireFromList: {
|
|
179
|
+
actionId: 'promotion.acquire-from-list',
|
|
180
|
+
moduleId: 'promotion',
|
|
181
|
+
cardType: 'promotion-offer-list',
|
|
182
|
+
label: '领取',
|
|
183
|
+
singleUse: false,
|
|
184
|
+
inputSchema: z.object({ offerRef: z.string().min(1) }),
|
|
185
|
+
execute: executeAcquire,
|
|
186
|
+
},
|
|
187
|
+
// 门店券行领取入口(2026-09-02):couponList 携 offerRef 的券——与优惠列表领取逐字同链路
|
|
188
|
+
// (executeAcquire 单源)。token 按 cardType 签发而注册表按 actionId 键控——两个卡上下文
|
|
189
|
+
// (shop-detail 卡直开 / shop-list 卡下钻浮层)= 两个变体 id;门店列表卡本体无领取按钮
|
|
190
|
+
// (按钮只在两个上下文打开的门店详情浮层券条目里)。
|
|
191
|
+
acquireFromShopDetail: {
|
|
192
|
+
actionId: 'promotion.acquire-from-shop-detail',
|
|
193
|
+
moduleId: 'promotion',
|
|
194
|
+
cardType: 'shop-detail',
|
|
195
|
+
label: '领取',
|
|
196
|
+
singleUse: false,
|
|
197
|
+
inputSchema: z.object({ offerRef: z.string().min(1) }),
|
|
198
|
+
execute: executeAcquire,
|
|
199
|
+
},
|
|
200
|
+
acquireFromShopList: {
|
|
201
|
+
actionId: 'promotion.acquire-from-shop-list',
|
|
202
|
+
moduleId: 'promotion',
|
|
203
|
+
cardType: 'shop-list',
|
|
204
|
+
label: '领取',
|
|
205
|
+
singleUse: false,
|
|
206
|
+
inputSchema: z.object({ offerRef: z.string().min(1) }),
|
|
207
|
+
execute: executeAcquire,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Code security: external business text is bounded and JSON-escaped before entering a log line.
|
|
213
|
+
function logText(value: unknown): string {
|
|
214
|
+
const text = typeof value === 'string' ? value : String(value ?? '');
|
|
215
|
+
const bounded = text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
|
216
|
+
return JSON.stringify(bounded);
|
|
217
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** promotion 卡 meta 聚合:9 个稳定传输卡型,renderer 内覆盖多个展示语义。 */
|
|
2
|
+
import promotionOfferList from './promotion-offer-list/meta';
|
|
3
|
+
import promotionOfferDetail from './promotion-offer-detail/meta';
|
|
4
|
+
import promotionEntitlementList from './promotion-entitlement-list/meta';
|
|
5
|
+
import promotionActivityList from './promotion-activity-list/meta';
|
|
6
|
+
import promotionActivityDetail from './promotion-activity-detail/meta';
|
|
7
|
+
import promotionActivityCategoryList from './promotion-activity-category-list/meta';
|
|
8
|
+
import promotionBestDeal from './promotion-best-deal/meta';
|
|
9
|
+
|
|
10
|
+
export const promotionCards = {
|
|
11
|
+
'promotion-offer-list': promotionOfferList,
|
|
12
|
+
'promotion-offer-detail': promotionOfferDetail,
|
|
13
|
+
'promotion-entitlement-list': promotionEntitlementList,
|
|
14
|
+
'promotion-activity-list': promotionActivityList,
|
|
15
|
+
'promotion-activity-detail': promotionActivityDetail,
|
|
16
|
+
'promotion-activity-category-list': promotionActivityCategoryList,
|
|
17
|
+
'promotion-best-deal': promotionBestDeal,
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { promotionActivityCategoryListCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof promotionActivityCategoryListCardDataSchema> = {
|
|
5
|
+
cardType: 'promotion-activity-category-list',
|
|
6
|
+
dataSchema: promotionActivityCategoryListCardDataSchema,
|
|
7
|
+
displayName: '活动分类',
|
|
8
|
+
description: '商场活动分类入口',
|
|
9
|
+
sampleData: { items: [{ categoryId: 'family', name: '亲子' }, { categoryId: 'market', name: '市集' }] },
|
|
10
|
+
schemaVersion: 1,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { promotionActivityDetailCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof promotionActivityDetailCardDataSchema> = {
|
|
5
|
+
cardType: 'promotion-activity-detail',
|
|
6
|
+
dataSchema: promotionActivityDetailCardDataSchema,
|
|
7
|
+
displayName: '活动详情',
|
|
8
|
+
description: '活动详情 + 场次 + 参与指南',
|
|
9
|
+
sampleData: {
|
|
10
|
+
presentation: 'detail',
|
|
11
|
+
activityRef: 'demo-act:3001',
|
|
12
|
+
title: '周末亲子市集',
|
|
13
|
+
subtitle: '会员限定体验',
|
|
14
|
+
categoryName: '亲子',
|
|
15
|
+
periodText: '8月22日 14:00',
|
|
16
|
+
location: '商场北座',
|
|
17
|
+
remainingQuota: 12,
|
|
18
|
+
registrationRequired: true,
|
|
19
|
+
registrationUrl: 'alipays://platformapi/startapp?appId=demo&page=v%2Fmarketing%2Factivity%2Fdetail%3Fid%3Ddemo-activity',
|
|
20
|
+
sessions: [{ sessionRef: 's_1', title: '第一场', startText: '周六 10:00', remainingQuota: 12, status: 'OPEN' }],
|
|
21
|
+
guideSteps: ['领取优惠券', '选择场次报名'],
|
|
22
|
+
guideItems: [{ title: '领取优惠券', description: '在会员中心完成资格校验' }, { title: '选择场次报名' }],
|
|
23
|
+
conditions: ['限会员本人参与'],
|
|
24
|
+
},
|
|
25
|
+
schemaVersion: 2,
|
|
26
|
+
migrate: (oldData) => oldData as Record<string, unknown>,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { promotionActivityListCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof promotionActivityListCardDataSchema> = {
|
|
5
|
+
cardType: 'promotion-activity-list',
|
|
6
|
+
dataSchema: promotionActivityListCardDataSchema,
|
|
7
|
+
displayName: '活动列表',
|
|
8
|
+
description: '营销活动列表(报名要求 + 名额)',
|
|
9
|
+
sampleData: { items: [{ activityRef: 'demo-act:3001', title: '周末亲子市集', subtitle: '会员限定体验', categoryName: '亲子', registrationRequired: true, remainingQuota: 12, periodText: '8月22日 14:00', availabilityStatus: 'AVAILABLE' }] },
|
|
10
|
+
schemaVersion: 1,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { promotionBestDealCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof promotionBestDealCardDataSchema> = {
|
|
5
|
+
cardType: 'promotion-best-deal',
|
|
6
|
+
dataSchema: promotionBestDealCardDataSchema,
|
|
7
|
+
displayName: '优惠测算',
|
|
8
|
+
description: '优惠测算结果或备选建议',
|
|
9
|
+
sampleData: {
|
|
10
|
+
authoritative: true,
|
|
11
|
+
originalAmountCents: 20000,
|
|
12
|
+
savingsCents: 3000,
|
|
13
|
+
expectedPayCents: 17000,
|
|
14
|
+
appliedOfferRefs: ['demo:coupon:demo'],
|
|
15
|
+
message: '预计节省 30 元',
|
|
16
|
+
},
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { promotionEntitlementListCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
import { samples } from './samples';
|
|
4
|
+
|
|
5
|
+
const meta: CardMeta<typeof promotionEntitlementListCardDataSchema> = {
|
|
6
|
+
cardType: 'promotion-entitlement-list',
|
|
7
|
+
dataSchema: promotionEntitlementListCardDataSchema,
|
|
8
|
+
displayName: '我的券',
|
|
9
|
+
description: '已领取资产列表(券码脱敏)',
|
|
10
|
+
sampleData: {
|
|
11
|
+
items: [{ entitlementRef: 'ent_1', title: '满 100 减 20 元券', entitlementType: 'COUPON', status: 'EFFECTIVE', codeMasked: 'AB**CD' }],
|
|
12
|
+
},
|
|
13
|
+
samples,
|
|
14
|
+
schemaVersion: 1,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default meta;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promotion-entitlement-list 附加预览样本(沉淀自 mall dev-gallery 我的券画廊)。
|
|
3
|
+
* 方法论:见同目录 ../promotion-offer-list/samples.ts 头注释。
|
|
4
|
+
*/
|
|
5
|
+
import type { CardSample } from '@tbox.cn/app-contracts';
|
|
6
|
+
import type { PromotionEntitlementListCardData } from '@tbox.cn/app-contracts-mall';
|
|
7
|
+
|
|
8
|
+
/** 券图占位:中性色块 + 品类色角标(与 offer-list 样本同族) */
|
|
9
|
+
function couponImage(accent: string): string {
|
|
10
|
+
return 'data:image/svg+xml;utf8,'
|
|
11
|
+
+ '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180">'
|
|
12
|
+
+ '<rect width="240" height="180" rx="12" fill="%23f2f4f7"/>'
|
|
13
|
+
+ `<rect x="0" y="0" width="240" height="52" rx="12" fill="%23${accent}" opacity="0.85"/>`
|
|
14
|
+
+ '<rect x="20" y="76" width="140" height="16" rx="4" fill="%23d5dae2"/>'
|
|
15
|
+
+ '<rect x="20" y="104" width="96" height="12" rx="3" fill="%23e3e7ed"/>'
|
|
16
|
+
+ '<circle cx="196" cy="128" r="26" fill="none" stroke="%23c6ccd6" stroke-width="4" stroke-dasharray="6 6"/>'
|
|
17
|
+
+ '</svg>';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const IMG = {
|
|
21
|
+
coffee: couponImage('8a5a3b'),
|
|
22
|
+
lifestyle: couponImage('3d5a80'),
|
|
23
|
+
dining: couponImage('9e2b25'),
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export const samples: CardSample<PromotionEntitlementListCardData>[] = [
|
|
27
|
+
{
|
|
28
|
+
label: '我的券·状态矩阵',
|
|
29
|
+
note: '待使用、已使用、已过期三态及动作降级同屏',
|
|
30
|
+
group: '我的券',
|
|
31
|
+
data: {
|
|
32
|
+
items: [
|
|
33
|
+
{ entitlementRef: 'ent:effective', title: '瑞幸咖啡|30元饮品代金券', imageUrl: IMG.coffee, entitlementType: 'COUPON', status: 'EFFECTIVE', codeMasked: '**** 6688', benefitText: '¥30', thresholdText: '无门槛', usageNotice: '1F · 到店点单可用', validUntilText: '2026-09-27 到期' },
|
|
34
|
+
{ entitlementRef: 'ent:used', title: 'KKV 120元优惠券', imageUrl: IMG.lifestyle, entitlementType: 'COUPON', status: 'USED', codeMasked: '**** 1058', benefitText: '¥105 · 8.8折', usageNotice: '已于 2026-08-26 使用', validUntilText: '已使用' },
|
|
35
|
+
{ entitlementRef: 'ent:expired', title: '南京大牌档|招牌菜品30元券', imageUrl: IMG.dining, entitlementType: 'COUPON', status: 'EXPIRED', benefitText: '¥30', usageNotice: '4F · 餐饮区', validUntilText: '已于 2026-08-20 过期' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: '我的券·缺图长文案',
|
|
41
|
+
note: '无图片、长标题和 UNKNOWN 状态的容错形态',
|
|
42
|
+
group: '我的券',
|
|
43
|
+
data: { items: [{ entitlementRef: 'ent:unknown', title: '会员限定超长名称测试|指定商户周末到店消费专享权益', entitlementType: 'COUPON', status: 'UNKNOWN', codeMasked: '**** 0000', benefitText: '满100减20', usageNotice: '使用门店和适用商品请以券详情说明为准', validUntilText: '有效期待品牌方确认' }] },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
label: '我的券·空态',
|
|
47
|
+
note: '账户没有券时保留去领券的语义引导',
|
|
48
|
+
group: '我的券',
|
|
49
|
+
data: { items: [], noResultsReason: '还没有优惠券,先去看看本周神券吧' },
|
|
50
|
+
},
|
|
51
|
+
];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { promotionOfferDetailCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof promotionOfferDetailCardDataSchema> = {
|
|
5
|
+
cardType: 'promotion-offer-detail',
|
|
6
|
+
dataSchema: promotionOfferDetailCardDataSchema,
|
|
7
|
+
displayName: '优惠详情',
|
|
8
|
+
description: '优惠详情 + 领取(动作 promotion.acquire)',
|
|
9
|
+
sampleData: {
|
|
10
|
+
presentation: 'coupon',
|
|
11
|
+
offerRef: 'demo:coupon:1001',
|
|
12
|
+
title: '满 100 减 20 元券',
|
|
13
|
+
offerType: 'COUPON',
|
|
14
|
+
benefitText: '满 100 减 20',
|
|
15
|
+
subtitle: '会员专享',
|
|
16
|
+
validityText: '有效期至 2026-08-31',
|
|
17
|
+
rules: ['单笔满 100 可用', '限领 1 张'],
|
|
18
|
+
steps: [{ title: '领取优惠', description: '进入会员券包完成领取' }],
|
|
19
|
+
claimable: true,
|
|
20
|
+
caveats: ['优惠资格、库存和最终优惠以领取或使用时为准'],
|
|
21
|
+
},
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { promotionOfferListCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
import { samples } from './samples';
|
|
4
|
+
|
|
5
|
+
const meta: CardMeta<typeof promotionOfferListCardDataSchema> = {
|
|
6
|
+
cardType: 'promotion-offer-list',
|
|
7
|
+
dataSchema: promotionOfferListCardDataSchema,
|
|
8
|
+
displayName: '优惠列表',
|
|
9
|
+
description: '优惠、促销、券与推荐四种 legacy 展示语义',
|
|
10
|
+
sampleData: {
|
|
11
|
+
presentation: 'promotion',
|
|
12
|
+
mode: 'search',
|
|
13
|
+
summary: '本周值得关注的活动',
|
|
14
|
+
items: [{ offerRef: 'demo:promotion:1001', title: '会员夏日礼遇', subtitle: '限时会员专享', offerType: 'PROMOTION', benefitText: '满额赠礼', claimable: true, availabilityStatus: 'AVAILABLE', validityText: '有效期至 2026-08-31', tags: ['会员'] }],
|
|
15
|
+
},
|
|
16
|
+
samples,
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default meta;
|