@tbox.cn/app-module-promotion 0.1.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
package/dist/server/index.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
InMemoryPromotionService,
|
|
3
|
-
|
|
3
|
+
inMemoryPromotion,
|
|
4
|
+
promotionCards,
|
|
5
|
+
resolvePromotionAcquisitionPort,
|
|
6
|
+
resolvePromotionActivitiesPort,
|
|
7
|
+
resolvePromotionEntitlementsPort,
|
|
8
|
+
resolvePromotionOffersPort,
|
|
4
9
|
serverModule
|
|
5
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-QOTX4XDS.js";
|
|
6
11
|
export {
|
|
7
12
|
InMemoryPromotionService,
|
|
8
|
-
|
|
13
|
+
inMemoryPromotion,
|
|
14
|
+
promotionCards,
|
|
15
|
+
resolvePromotionAcquisitionPort,
|
|
16
|
+
resolvePromotionActivitiesPort,
|
|
17
|
+
resolvePromotionEntitlementsPort,
|
|
18
|
+
resolvePromotionOffersPort,
|
|
9
19
|
serverModule
|
|
10
20
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tbox.cn/app-module-promotion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "商圈营销模块:资格校验、推荐降级链、券卡(codegen 源)。",
|
|
6
6
|
"exports": {
|
|
@@ -18,32 +18,42 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@tbox.cn/app-
|
|
22
|
-
"@tbox.cn/app-contracts": "
|
|
23
|
-
"@tbox.cn/app-
|
|
21
|
+
"@tbox.cn/app-contracts": "0.9.0",
|
|
22
|
+
"@tbox.cn/app-contracts-mall": "0.9.0",
|
|
23
|
+
"@tbox.cn/app-sdk": "0.17.0",
|
|
24
|
+
"jsbarcode": "^3.12.3",
|
|
25
|
+
"qrcode": "^1.5.4"
|
|
24
26
|
},
|
|
25
27
|
"peerDependencies": {
|
|
26
|
-
"
|
|
27
|
-
"react": "
|
|
28
|
+
"@phosphor-icons/react": "2.1.10",
|
|
29
|
+
"react": "18.3.1",
|
|
30
|
+
"zod": "3.25.76"
|
|
28
31
|
},
|
|
29
32
|
"devDependencies": {
|
|
30
|
-
"@types/
|
|
31
|
-
"@types/node": "
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
33
|
+
"@types/express": "4.17.25",
|
|
34
|
+
"@types/node": "24.13.3",
|
|
35
|
+
"@types/qrcode": "^1.5.6",
|
|
36
|
+
"@types/react": "18.3.31",
|
|
37
|
+
"@types/react-dom": "18.3.7",
|
|
38
|
+
"express": "4.22.2",
|
|
39
|
+
"happy-dom": "20.11.2",
|
|
40
|
+
"react-dom": "18.3.1",
|
|
41
|
+
"tsup": "8.5.1",
|
|
42
|
+
"typescript": "5.9.3",
|
|
43
|
+
"vitest": "4.1.10",
|
|
44
|
+
"zod": "3.25.76"
|
|
37
45
|
},
|
|
38
46
|
"files": [
|
|
39
47
|
"dist",
|
|
40
48
|
"src",
|
|
41
49
|
"tests",
|
|
42
|
-
"tbox.
|
|
50
|
+
"tbox.module.json",
|
|
43
51
|
"tsconfig.json",
|
|
44
52
|
"tsup.config.ts",
|
|
45
53
|
"README.md",
|
|
46
|
-
"LICENSE"
|
|
54
|
+
"LICENSE",
|
|
55
|
+
"skills",
|
|
56
|
+
"AGENTS.md"
|
|
47
57
|
],
|
|
48
58
|
"engines": {
|
|
49
59
|
"node": ">=20.0.0"
|
|
@@ -52,6 +62,9 @@
|
|
|
52
62
|
"access": "public"
|
|
53
63
|
},
|
|
54
64
|
"license": "MIT",
|
|
65
|
+
"sideEffects": [
|
|
66
|
+
"**/*.css"
|
|
67
|
+
],
|
|
55
68
|
"scripts": {
|
|
56
69
|
"typecheck": "tsc --noEmit",
|
|
57
70
|
"test": "vitest run",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: promotion
|
|
3
|
+
description: 商圈营销 SOP——优惠券搜索/领取、我的券查询、最优优惠测算、营销活动与场次报名指引的标准流程。
|
|
4
|
+
routingTerms:
|
|
5
|
+
- 领券
|
|
6
|
+
- 优惠券
|
|
7
|
+
- 优惠
|
|
8
|
+
- 券
|
|
9
|
+
- 折扣
|
|
10
|
+
- 团购
|
|
11
|
+
- 活动
|
|
12
|
+
- 市集
|
|
13
|
+
- 我的券
|
|
14
|
+
- 券码
|
|
15
|
+
- 满减
|
|
16
|
+
- 划算
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# 商圈营销 SOP
|
|
20
|
+
|
|
21
|
+
## 角色与语气
|
|
22
|
+
|
|
23
|
+
你是商圈营销助手,像一位懂行的逛街搭子:熟悉商场里的券和活动,帮顾客用最少的钱买到想要的东西。说话热络但不推销,清楚但不堆砌:
|
|
24
|
+
|
|
25
|
+
- **划算说到位**:直接说省多少、怎么用("满 200 减 50,结账前出示就行"),不背营销术语
|
|
26
|
+
- **数字不裸贴**:面额、门槛带语境("满 200 可用"而不是 `threshold: 20000`)
|
|
27
|
+
- **不替用户做决定**:推荐最优组合时给出理由,把选择权留给用户
|
|
28
|
+
- **领取类动作给出口**:告诉用户"点开券看看,里面就能领",不代劳也不催
|
|
29
|
+
|
|
30
|
+
❌ `已为您领取优惠券!券号为 XJFK********82,请妥善保管。`
|
|
31
|
+
|
|
32
|
+
✅ `这张券点开就能领~点卡片上那张券,里面有领取按钮,领完在"我的券"里随时能看到。`
|
|
33
|
+
|
|
34
|
+
## 能力地图
|
|
35
|
+
|
|
36
|
+
| 用户想要 | 调用 | 出卡 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| 看有哪些可领取优惠券 | `listCoupons(mode=CLAIMABLE)` | `promotion-offer-list` |
|
|
39
|
+
| 某张券的详情 | `getOfferDetail` | `promotion-offer-detail` |
|
|
40
|
+
| 领券 | `listCoupons(mode=CLAIMABLE)`(用户在券行浮层内领取) | `promotion-offer-list` → `promotion-entitlement-list` |
|
|
41
|
+
| 我的券/已领取/卡包 | `listEntitlements` 或 `listCoupons(mode=OWNED)` | `promotion-entitlement-list` |
|
|
42
|
+
| 最优优惠测算 | `calculateBestDeal` | `promotion-best-deal` |
|
|
43
|
+
| 活动列表/详情/场次/指南 | `listActivities` / `getActivityDetail` / `listActivitySessions` / `getActivityGuide` | 活动系列卡 |
|
|
44
|
+
| 活动类型 | `listActivityCategories` | `promotion-activity-category-list` |
|
|
45
|
+
| 营销专题 | `listPromotions` / `getPromotionDetail` / `getParticipationGuide` | 专题系列卡 |
|
|
46
|
+
|
|
47
|
+
## 出卡规则
|
|
48
|
+
工具结果含 cards 候选时,回复末尾另起一行输出 `<agent-ui-cards>` 引用本轮卡片 id(空格分隔)——
|
|
49
|
+
不在正文里只说「已为你找到优惠」而省略卡片引用,用户看不到卡就无法领券。
|
|
50
|
+
|
|
51
|
+
## 场景细则
|
|
52
|
+
|
|
53
|
+
### 一:找优惠/看有哪些券
|
|
54
|
+
|
|
55
|
+
触发:"有什么优惠""有没有券""现在有什么折扣""推荐优惠""有没有满减"。
|
|
56
|
+
|
|
57
|
+
用户问“有什么可领取/可领优惠券”时,必须调 `listCoupons` 且传 `mode=CLAIMABLE`,出可领取优惠券列表;不得调用 `listEntitlements`,不得把已领取券包当作答案。只有出现“我的/已领取/券包/持有”才进入 OWNED 流程。泛优惠(包含活动、团购)才调 `searchOffers`。用户想了解某张券的详情(面额/门槛/有效期/适用规则)时,调 `getOfferDetail`。
|
|
58
|
+
|
|
59
|
+
**数据禁编**:面额、门槛、库存、有效期一律从工具返回读取,禁止推断或声称任何未查得数据——券规则说错一条,结账时就有一场纠纷。
|
|
60
|
+
|
|
61
|
+
### 二:要领券
|
|
62
|
+
|
|
63
|
+
触发:"我要领这张券""帮我领一下""领取优惠"。
|
|
64
|
+
|
|
65
|
+
处理方式(闭环 4 步,缺一不可):
|
|
66
|
+
|
|
67
|
+
1. 调 `listCoupons`(或 `searchOffers`)出券列表卡;用户想要某张指定的券就带 keyword 精筛
|
|
68
|
+
2. 用户点券行拉起浮层,在浮层里点"领/抢"(`promotion.acquire-from-list` 动作,grant 授权闭环)
|
|
69
|
+
3. 领取结果按三种终态回到用户面前:
|
|
70
|
+
- `SUCCEEDED`:直接出 `promotion-entitlement-list`,刚领的券连券码一起可见
|
|
71
|
+
- `PENDING_AUTHORITY`:无卡,一行提示告知稍后在"我的券"查看
|
|
72
|
+
- `REJECTED`:无卡,一行提示优先原样展示 Provider 返回的业务 message,并附 code;message 缺失时使用已知码语义兜底,未知码使用通用失败文案
|
|
73
|
+
|
|
74
|
+
⚠️ **禁止**:
|
|
75
|
+
|
|
76
|
+
- 未经用户点击确认卡直接代领(无写工具——领取是写操作,授权闭环必须由用户触发)
|
|
77
|
+
- 在正文中预判或承诺领取成功(以卡为准——厂商侧库存与资格是实时校验的)
|
|
78
|
+
- 向用户展示完整券码(脱敏为厂商与卡包职责,泄露券码等于泄露钱)
|
|
79
|
+
|
|
80
|
+
用户事后问"领到了吗" → 调 `listEntitlements` 查卡包确认。
|
|
81
|
+
|
|
82
|
+
> 详见 `references/claim-loop.md`
|
|
83
|
+
|
|
84
|
+
### 三:查"我的券"
|
|
85
|
+
|
|
86
|
+
触发:"我有哪些券""我的卡包""我领了什么券""券码是什么"。
|
|
87
|
+
|
|
88
|
+
调 `listEntitlements`,返回非空时**必须**出 `promotion-entitlement-list` 卡,不得只在正文列券而省略卡片引用。只有出现“我的/已领取/券包/券码”语义时才进入此流程;“可领取/有什么优惠”永远走上面的 CLAIMABLE 流程。
|
|
89
|
+
|
|
90
|
+
**未入会分支**:用户已登录但未入会时,`listEntitlements`/`listCoupons(mode=OWNED)` 出参已含 `member-enrollment-cta` 引导卡——**直接引用该卡 id 即可**,无需再调 member 域 `queryEnrollmentCta`;正文简述“点击卡片完成入会后可查看券包”。
|
|
91
|
+
|
|
92
|
+
### 四:问最划算的优惠组合/优惠测算
|
|
93
|
+
|
|
94
|
+
触发:"哪个优惠最合适""满减怎么算最省""帮我算最优组合""怎么买划算"。
|
|
95
|
+
|
|
96
|
+
调 `calculateBestDeal`(需传 `currentAmountCents` 消费金额)。厂商支持时返回确定性最优方案;厂商不支持时只返回文字说明,不得构造 `promotion-best-deal` 旧卡或伪造测算结论。
|
|
97
|
+
|
|
98
|
+
### 五:查活动/报名
|
|
99
|
+
|
|
100
|
+
触发:"商场有什么活动""本周活动""亲子活动""市集""活动怎么报名"。
|
|
101
|
+
|
|
102
|
+
- 活动列表 → 调 `listActivities`,查"本周活动"时**必须传本周的 `startsAfter` 与 `endsBefore`**,不得把"当前活动"误当"本周活动";结果非空时**必须**出 `promotion-activity-list` 卡(不得用活动分类卡替代)
|
|
103
|
+
- 活动详情 → 调 `getActivityDetail`
|
|
104
|
+
- 可选场次 → 调 `listActivitySessions`(只读,不执行报名)
|
|
105
|
+
- 参与步骤/报名条件 → 调 `getActivityGuide`
|
|
106
|
+
- 用户只问活动有哪些类型 → 调 `listActivityCategories`,出 `promotion-activity-category-list` 卡
|
|
107
|
+
|
|
108
|
+
**报名由用户在厂商页面确认**:活动详情卡提供报名入口,**模型不代用户报名**,结果以厂商页面为准。
|
|
109
|
+
|
|
110
|
+
### 六:查营销专题/促销活动
|
|
111
|
+
|
|
112
|
+
触发:"有没有满减活动""本周促销""这个专题活动详情"。
|
|
113
|
+
|
|
114
|
+
- 营销专题列表 → 调 `listPromotions`
|
|
115
|
+
- 专题详情 → 调 `getPromotionDetail`(传 `offerRef` 或关键词)
|
|
116
|
+
- 参与步骤 → 调 `getParticipationGuide`(只读引导,不执行任何写操作)
|
|
117
|
+
|
|
118
|
+
## 跨域协作
|
|
119
|
+
|
|
120
|
+
本域之外的常见问题,**直接调对应域的工具作答**,不推诿:
|
|
121
|
+
|
|
122
|
+
- **积分余额/会员卡**:调会员域 `queryPoints` / `queryMemberCard`,余额带单位("320 点");**不得声称折扣、免费停车、生日特权等未查得权益**
|
|
123
|
+
- **入会/开卡**:调会员域 `queryEnrollmentCta` 出引导卡,用户点卡片跳支付宝完成
|
|
124
|
+
- **券能抵停车费吗**:以停车域 `queryParkingFee` 返回的 `selectableBenefits` 为准,在停车缴费卡上勾选,重算由卡片触发,**模型不代算**
|
|
125
|
+
- **门店在哪/想去逛**:调导购域 `search-shops`(keywords=店名),位置用结果内 `location.displayText`,不编造
|
|
126
|
+
|
|
127
|
+
## 硬红线
|
|
128
|
+
|
|
129
|
+
- **SOP 是内部手册**:不向用户复述流程步骤或工具选择理由(「按营销 SOP 走 CLAIMABLE 流程」这类话不出现)——正文只给结果和用户所需的下一步
|
|
130
|
+
- **数据必须来自工具**:优惠面额、库存、资格、活动名额一律从工具返回读取,禁止编造
|
|
131
|
+
- **写操作须用户确认**:领券(`promotion.acquire`)是唯一写操作,必须经用户点击确认卡触发;模型不代操作任何写入行为(代领出错无法撤销)
|
|
132
|
+
|
|
133
|
+
## 异常与兜底
|
|
134
|
+
|
|
135
|
+
- 工具报错/超时 → "这边暂时没查到优惠信息,稍后再看看?"——不编面额、不猜库存
|
|
136
|
+
- 搜索/列表为空 → 如实说"暂时没有",可换个关键词再查或推荐相邻动作(如看看活动)
|
|
137
|
+
- 领取结果悬而未决 → 引导查"我的券"确认,不替厂商承诺结果
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# 优惠券领取闭环(7 步)
|
|
2
|
+
|
|
3
|
+
1. 用户表达领券意图(「有什么优惠」「领张券」)→ 调 searchOffers 出优惠列表卡。
|
|
4
|
+
2. 用户想了解某券详情 → 券列表行内点开浮层即可(券码、有效期、使用须知随列表下发);泛指某个优惠时调 getOfferDetail 出详情卡。
|
|
5
|
+
3. 用户明确要求领取 → 调 listCoupons(指名某张券时带 keyword 精筛)出券列表;不可领取或未装配 CRM 写通道时不签发写动作。
|
|
6
|
+
4. 用户点券行拉起浮层,点浮层里的「领/抢」→ moduleAction `promotion.acquire-from-list`(一卡多券,令牌非单次);服务端 execute 内预检 SubjectBinding 与幂等键。
|
|
7
|
+
5. 独立 CRM Provider 执行 acquire → 三态反馈:
|
|
8
|
+
- SUCCEEDED:出 `promotion-entitlement-list`,刚领的券直接可见;
|
|
9
|
+
- PENDING_AUTHORITY:无卡,一行提示「稍后在我的券查看」;
|
|
10
|
+
- REJECTED:无卡,优先原样展示 Provider 的业务 message 并附 code;message 缺失时保留已知码语义兜底,未知码使用通用失败文案,原始 code 不丢失。
|
|
11
|
+
6. 用户问「领到了吗」→ listEntitlements 查卡包确认。
|
|
12
|
+
7. 使用咨询(怎么用/门槛)→ 引用我的券卡上的字段回答,必要时 getOfferDetail 取规则。
|
|
13
|
+
|
|
14
|
+
## 禁止
|
|
15
|
+
|
|
16
|
+
- 禁止文字里直接承诺领取成功(以动作返回的卡或提示为准)。
|
|
17
|
+
- 禁止绕过卡片按钮以任何工具代领(无写工具)。
|
|
18
|
+
- 禁止向用户展示完整券码(脱敏为厂商与卡包职责)。
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* module-promotion 客户端卡片聚合(0.4.0 D6:一卡一目录)。
|
|
3
|
+
*/
|
|
4
|
+
export { default as PromotionOfferListCard } from './promotion-offer-list';
|
|
5
|
+
export { default as PromotionOfferDetailCard } from './promotion-offer-detail';
|
|
6
|
+
export { default as PromotionEntitlementListCard } from './promotion-entitlement-list';
|
|
7
|
+
export { default as PromotionActivityListCard } from './promotion-activity-list';
|
|
8
|
+
export { default as PromotionActivityDetailCard } from './promotion-activity-detail';
|
|
9
|
+
export { default as PromotionActivityCategoryListCard } from './promotion-activity-category-list';
|
|
10
|
+
export { default as PromotionBestDealCard } from './promotion-best-deal';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { GameController, Palette, Sparkle, Storefront } from '@phosphor-icons/react';
|
|
2
|
+
import { ItemActionsRow } from '@tbox.cn/app-sdk/client';
|
|
3
|
+
import type { PromotionActivityCategoryListCardData } from '@tbox.cn/app-contracts-mall';
|
|
4
|
+
|
|
5
|
+
const icons = [GameController, Storefront, Palette, Sparkle] as const;
|
|
6
|
+
|
|
7
|
+
export function PromotionActivityCategoryListCard(props: {
|
|
8
|
+
data: PromotionActivityCategoryListCardData;
|
|
9
|
+
cardId?: string;
|
|
10
|
+
isHistory?: boolean;
|
|
11
|
+
}) {
|
|
12
|
+
const { data, cardId = '', isHistory = false } = props;
|
|
13
|
+
return (
|
|
14
|
+
<section className="promotion-card promotion-activity promotion-activity--categories">
|
|
15
|
+
<header className="promotion-card__header promotion-card__header--compact">
|
|
16
|
+
<span className="promotion-card__mark"><Sparkle weight="fill" /></span>
|
|
17
|
+
<div><span className="promotion-card__eyebrow">会员活动</span><h3>探索会员活动</h3><p>按兴趣找到你的专属周末</p></div>
|
|
18
|
+
</header>
|
|
19
|
+
<div className="promotion-activity__categories">
|
|
20
|
+
{data.items.map((item, index) => {
|
|
21
|
+
const Icon = icons[index % icons.length];
|
|
22
|
+
return <article key={item.categoryId}>
|
|
23
|
+
<span className={`promotion-activity__category-icon promotion-activity__category-icon--${index % 4}`}><Icon weight="duotone" /></span>
|
|
24
|
+
<span><strong>{item.name}</strong><small>发现精彩活动</small></span>
|
|
25
|
+
<ItemActionsRow actions={item.itemActions ?? []} cardId={cardId} isHistory={isHistory} className="promotion-item-actions" chipClassName="promotion-chip" />
|
|
26
|
+
</article>;
|
|
27
|
+
})}
|
|
28
|
+
</div>
|
|
29
|
+
{!data.items.length && <div className="promotion-card__empty"><Sparkle size={28} /><span>{data.noResultsReason ?? '暂无活动分类'}</span></div>}
|
|
30
|
+
</section>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default PromotionActivityCategoryListCard;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ArrowSquareOut, CalendarDots, CheckCircle, Clock, Crown, ListChecks, MapPin, Sparkle, Users } from '@phosphor-icons/react';
|
|
2
|
+
import type { PromotionActivityDetailCardData } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
import { sendCardAction } from '@tbox.cn/app-sdk/client';
|
|
4
|
+
import { ImageWithFallback } from '../../components/ImageWithFallback';
|
|
5
|
+
|
|
6
|
+
const sessionText: Record<string, string> = { OPEN: '可选', FULL: '已满', CLOSED: '已结束', UNKNOWN: '待确认' };
|
|
7
|
+
|
|
8
|
+
export function PromotionActivityDetailCard({ data, cardId = '', isHistory = false }: { data: PromotionActivityDetailCardData; cardId?: string; isHistory?: boolean }) {
|
|
9
|
+
if (data.presentation === 'schedule') return <ActivitySchedule data={data} />;
|
|
10
|
+
if (data.presentation === 'guide') return <ActivityGuide data={data} />;
|
|
11
|
+
return <ActivityDetail data={data} cardId={cardId} isHistory={isHistory} />;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ActivityDetail({ data, cardId, isHistory }: { data: PromotionActivityDetailCardData; cardId: string; isHistory: boolean }) {
|
|
15
|
+
const status = data.remainingQuota === 0 ? 'FULL' : 'AVAILABLE';
|
|
16
|
+
return (
|
|
17
|
+
<section className="promotion-card promotion-activity promotion-activity--detail">
|
|
18
|
+
<div className="promotion-activity__cover">
|
|
19
|
+
<ImageWithFallback src={data.coverUrl} alt={data.title} className="promotion-activity__cover-image" />
|
|
20
|
+
<span className="promotion-activity__cover-shade" />
|
|
21
|
+
<div className="promotion-activity__cover-meta"><span><Crown weight="fill" />会员专享体验</span><span className={`promotion-activity__status promotion-activity__status--${status.toLowerCase()}`}>{status === 'FULL' ? '已满' : '可报名'}</span></div>
|
|
22
|
+
</div>
|
|
23
|
+
<div className="promotion-activity__detail-body">
|
|
24
|
+
<small>{data.jumpOnly ? '首页精选 · 可跳转内容' : `活动详情 · ${data.categoryName ?? '会员活动'}`}</small>
|
|
25
|
+
<h3>{data.title}</h3>
|
|
26
|
+
{(data.subtitle ?? data.description) && <p>{data.subtitle ?? data.description}</p>}
|
|
27
|
+
<div className="promotion-activity__spotlight">
|
|
28
|
+
<span><CalendarDots weight="duotone" /><small>{data.jumpOnly ? '内容说明' : '活动时间'}</small><strong>{data.periodText ?? (data.jumpOnly ? '详情以跳转页面为准' : '以活动页为准')}</strong></span>
|
|
29
|
+
<span><Users weight="duotone" /><small>活动名额</small><strong>{data.remainingQuota !== undefined ? `剩余 ${data.remainingQuota} 席` : data.registrationRequired ? '需提前报名' : '无需报名'}</strong></span>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="promotion-activity__venue"><MapPin weight="fill" /><span><small>活动地点</small><strong>{data.location ?? '当前商场'}</strong></span></div>
|
|
32
|
+
{data.registrationRequired && <div className="promotion-activity__benefit"><Crown weight="fill" /><span><b>会员专享礼遇</b>完成会员身份校验后可参与</span></div>}
|
|
33
|
+
{data.notice && <div className="promotion-activity__detail-notice"><Clock weight="duotone" /><span>{data.notice}</span></div>}
|
|
34
|
+
{(data.sessions.length > 0 || data.guideSteps.length > 0) && <div className="promotion-activity__readonly"><ListChecks weight="fill" />可继续询问参与指南和可选场次</div>}
|
|
35
|
+
{(data.registrationRequired || data.jumpOnly) && data.registrationUrl && (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
className="promotion-activity__register"
|
|
39
|
+
disabled={isHistory}
|
|
40
|
+
onClick={() => sendCardAction(cardId, { type: 'openLink', url: data.registrationUrl! })}
|
|
41
|
+
>
|
|
42
|
+
<ArrowSquareOut weight="bold" />
|
|
43
|
+
{data.jumpOnly ? (data.jumpLabel ?? '打开小程序') : '去支付宝报名'}
|
|
44
|
+
</button>
|
|
45
|
+
)}
|
|
46
|
+
{data.jumpOnly && !data.registrationUrl && <div className="promotion-activity__detail-notice"><Clock weight="duotone" /><span>该首页资源暂无可用跳转链接</span></div>}
|
|
47
|
+
</div>
|
|
48
|
+
</section>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ActivitySchedule({ data }: { data: PromotionActivityDetailCardData }) {
|
|
53
|
+
return (
|
|
54
|
+
<section className="promotion-card promotion-activity promotion-activity--schedule">
|
|
55
|
+
<ActivityHeader title="选择心仪场次" subtitle={data.title} icon="calendar" />
|
|
56
|
+
<div className="promotion-activity__session-summary"><CalendarDots weight="duotone" /><span><small>可选场次</small><strong>{data.sessions.length} 场</strong></span><em>实时名额</em></div>
|
|
57
|
+
<div className="promotion-activity__sessions">
|
|
58
|
+
{data.sessions.map((session, index) => <article key={session.sessionRef} className={session.status === 'OPEN' ? 'is-available' : 'is-full'}><b>{String(index + 1).padStart(2, '0')}</b><span><strong>{session.title}</strong><small>{session.startText}{session.endText ? ` - ${session.endText}` : ''}</small></span><span><Users weight="duotone" />{session.remainingQuota !== undefined ? `余 ${session.remainingQuota} 席` : session.location ?? '名额实时'}</span><em>{sessionText[session.status]}</em></article>)}
|
|
59
|
+
{!data.sessions.length && <div className="promotion-activity__empty-state"><Clock weight="duotone" /><span><strong>场次正在确认</strong><small>稍后再来查看最新安排</small></span></div>}
|
|
60
|
+
</div>
|
|
61
|
+
<p className="promotion-activity__readonly"><CheckCircle weight="fill" />当前仅展示场次与名额,不会直接提交报名</p>
|
|
62
|
+
</section>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ActivityGuide({ data }: { data: PromotionActivityDetailCardData }) {
|
|
67
|
+
const steps: Array<{ title: string; description?: string }> =
|
|
68
|
+
data.guideItems ?? data.guideSteps.map((title) => ({ title }));
|
|
69
|
+
return (
|
|
70
|
+
<section className="promotion-card promotion-activity promotion-activity--guide">
|
|
71
|
+
<ActivityHeader title="参与指南" subtitle={data.title} icon="guide" />
|
|
72
|
+
<div className="promotion-activity__guide-banner"><Sparkle weight="fill" /><span><small>HOW TO JOIN</small><strong>提前准备,到场更从容</strong></span></div>
|
|
73
|
+
<ol className="promotion-activity__steps">{steps.map((step, index) => <li key={index}><b>{index + 1}</b><span><strong>{step.title}</strong><small>{step.description ?? ''}</small></span></li>)}</ol>
|
|
74
|
+
{data.conditions.length > 0 && <div className="promotion-activity__requirements"><b>参与条件</b><div>{data.conditions.map((item) => <span key={item}><CheckCircle weight="fill" />{item}</span>)}</div></div>}
|
|
75
|
+
<p className="promotion-activity__readonly"><CheckCircle weight="fill" />{data.notice ?? '当前仅提供参与说明,不会直接提交报名'}</p>
|
|
76
|
+
</section>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ActivityHeader({ title, subtitle, icon }: { title: string; subtitle: string; icon: 'calendar' | 'guide' }) {
|
|
81
|
+
return <header className="promotion-card__header promotion-card__header--compact"><span className="promotion-card__mark">{icon === 'calendar' ? <CalendarDots weight="duotone" /> : <ListChecks weight="duotone" />}</span><div><span className="promotion-card__eyebrow">会员活动</span><h3>{title}</h3><p>{subtitle}</p></div></header>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default PromotionActivityDetailCard;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CalendarDots, Sparkle, Ticket } from '@phosphor-icons/react';
|
|
2
|
+
import { sendCardAction } from '@tbox.cn/app-sdk/client';
|
|
3
|
+
import type { PromotionActivityListCardData } from '@tbox.cn/app-contracts-mall';
|
|
4
|
+
import { ImageWithFallback } from '../../components/ImageWithFallback';
|
|
5
|
+
import activityHeaderArtUrl from '../../assets/promotion-activity-header-pea-v2.jpg';
|
|
6
|
+
|
|
7
|
+
const statusText: Record<string, string> = { AVAILABLE: '可报名', FULL: '已满', ENDED: '已结束', UPCOMING: '即将开始', UNKNOWN: '待确认' };
|
|
8
|
+
|
|
9
|
+
export function PromotionActivityListCard(props: {
|
|
10
|
+
data: PromotionActivityListCardData;
|
|
11
|
+
cardId?: string;
|
|
12
|
+
isHistory?: boolean;
|
|
13
|
+
}) {
|
|
14
|
+
const { data, cardId = '', isHistory = false } = props;
|
|
15
|
+
return (
|
|
16
|
+
<section className="promotion-card promotion-activity promotion-activity--list">
|
|
17
|
+
<header className="promotion-activity__header promotion-activity__header--pea">
|
|
18
|
+
<img src={activityHeaderArtUrl} alt="" aria-hidden="true" className="promotion-activity__header-art" />
|
|
19
|
+
<div className="promotion-activity__header-copy">
|
|
20
|
+
<div className="promotion-activity__header-title-row">
|
|
21
|
+
<Sparkle weight="fill" />
|
|
22
|
+
<h3>活动中心</h3>
|
|
23
|
+
</div>
|
|
24
|
+
<p>发现商场近期精彩活动</p>
|
|
25
|
+
</div>
|
|
26
|
+
</header>
|
|
27
|
+
<div className="promotion-activity__list">
|
|
28
|
+
{data.items.map((item) => {
|
|
29
|
+
const status = item.availabilityStatus ?? (item.remainingQuota === 0 ? 'FULL' : 'UNKNOWN');
|
|
30
|
+
const action = item.itemActions?.find((candidate) => candidate.kind === 'send' || candidate.kind === 'link');
|
|
31
|
+
const jumpOnly = item.jumpOnly === true;
|
|
32
|
+
return <button
|
|
33
|
+
key={item.activityRef}
|
|
34
|
+
type="button"
|
|
35
|
+
disabled={isHistory || !action}
|
|
36
|
+
aria-label={action?.label ?? (jumpOnly ? `打开${item.title}` : `查看${item.title}详情`)}
|
|
37
|
+
className={status === 'FULL' || status === 'ENDED' ? 'is-muted' : ''}
|
|
38
|
+
onClick={() => {
|
|
39
|
+
if (!action || isHistory) return;
|
|
40
|
+
if (action.kind === 'link') {
|
|
41
|
+
sendCardAction(cardId, { type: 'openLink', url: action.url });
|
|
42
|
+
} else {
|
|
43
|
+
sendCardAction(cardId, { type: 'sendMessage', value: action.value ?? action.label, visible: true });
|
|
44
|
+
}
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<span className="promotion-activity__visual"><ImageWithFallback src={item.coverUrl} alt={item.title} className="promotion-activity__thumb" /><span className={`promotion-activity__status promotion-activity__status--${status.toLowerCase()}`}>{statusText[status]}</span></span>
|
|
48
|
+
<span className="promotion-activity__copy"><small><Sparkle weight="fill" />{item.categoryName ?? (jumpOnly ? '首页精选' : '会员活动')}</small><strong>{item.title}</strong><span><CalendarDots weight="duotone" />{item.periodText ?? (jumpOnly ? '点击打开查看内容' : '时间以活动页为准')}</span><em>{item.remainingQuota !== undefined ? item.remainingQuota > 0 ? `仅余 ${item.remainingQuota} 席` : '名额已满' : jumpOnly ? (item.jumpLabel ?? '打开查看') : item.registrationRequired ? '需提前报名' : '无需报名'}</em></span>
|
|
49
|
+
</button>;
|
|
50
|
+
})}
|
|
51
|
+
</div>
|
|
52
|
+
{!data.items.length && <div className="promotion-card__empty"><CalendarDots size={28} /><span>{data.noResultsReason ?? '近期暂无活动'}</span></div>}
|
|
53
|
+
<footer className="promotion-activity__footer"><Ticket weight="duotone" />会员活动名额实时变化,以活动详情为准</footer>
|
|
54
|
+
</section>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default PromotionActivityListCard;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Calculator, TrendUp } from '@phosphor-icons/react';
|
|
2
|
+
import type { PromotionBestDealCardData } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const money = (cents: number) => (cents / 100).toFixed(2);
|
|
5
|
+
|
|
6
|
+
export function PromotionBestDealCard({ data }: { data: PromotionBestDealCardData }) {
|
|
7
|
+
const savings = data.savingsCents ?? 0;
|
|
8
|
+
const payable = data.expectedPayCents ?? Math.max(0, data.originalAmountCents - savings);
|
|
9
|
+
return (
|
|
10
|
+
<section className="promotion-card promotion-deal" aria-label="优惠测算">
|
|
11
|
+
<header className="promotion-card__header"><span className="promotion-card__mark promotion-card__mark--cyan"><Calculator weight="duotone" /></span><div><span className="promotion-card__eyebrow">优惠方案</span><h3>优惠测算</h3><p>{data.authoritative ? '实时优惠测算' : '结算前优惠建议'}</p></div></header>
|
|
12
|
+
{data.authoritative ? <div className="promotion-deal__hero"><span><small>预计实付</small><strong>¥{money(payable)}</strong></span><em>预计节省 ¥{money(savings)}</em></div> : <div className="promotion-deal__notice"><TrendUp weight="duotone" /><span><b>当前为建议结果</b>{data.message ?? '最终金额请在结算页确认'}</span></div>}
|
|
13
|
+
<div className="promotion-deal__metrics"><span><small>消费金额</small><strong>¥{money(data.originalAmountCents)}</strong></span><span><small>预计节省</small><strong>¥{money(savings)}</strong></span><span><small>候选优惠</small><strong>{data.candidates?.length ?? data.appliedOfferRefs.length}</strong></span></div>
|
|
14
|
+
</section>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default PromotionBestDealCard;
|