feat(tip): support dynamic gift products

This commit is contained in:
2026-07-21 13:19:45 +08:00
parent 55cb98ed14
commit 37ff69020b
62 changed files with 2325 additions and 1085 deletions
+403
View File
@@ -0,0 +1,403 @@
# 虚拟礼物商品与付款后文案 API
## 一、用途
前端按当前角色一次获取全部启用的礼物品类和商品,在本地完成品类切换与商品筛选。页面不得写死咖啡、鲜花、服装等品类,也不得根据 `planId` 推算名称、图片或价格。付款成功后,前端使用订单号获取稳定的角色感谢文案。
## 三、推荐接入流程
1. 当前角色确定后,只请求一次 `GET /api/payment/gift-products?characterId=<CHARACTER_ID>`
2. 使用 `data.categories` 渲染品类栏,使用 `data.plans` 保存完整商品目录。
3. 用户切换品类时只在前端按 `product.category` 本地筛选,不再请求后端。
4. 使用商品自身的 `planName``description``imageUrl``amountCents``currency` 渲染通用商品卡片。
5. 用户选择商品后,使用该商品原始 `planId` 创建订单。
6. 用户切换商品时清除旧 `orderId``clientSecret` 和支付组件状态,再创建新订单。
7. Stripe 使用本次订单返回的 `payParams.clientSecret` 挂载 Payment Element。
8. 每 3 至 5 秒轮询订单状态,直到状态变成 `paid``failed``expired`
9. 状态为 `paid` 后调用 `POST /api/payment/tip-message`,展示 `data.message`
> `amountCents`、`currency` 和 `planId` 必须来自同一条商品数据。后端下单时会再次按 `planId` 校验真实价格,前端展示值不得作为收费依据。
---
## 四、一次获取完整礼物目录
### 4.1 请求
```http
GET /api/payment/gift-products?characterId=elio
```
- 完整地址:`https://proapi.banlv-ai.com/api/payment/gift-products`
- 兼容别名:`GET /api/payment/tip-plans`
- 登录鉴权:不需要
- 请求格式:Query String
### 4.2 查询参数
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
| --- | --- | --- | --- | --- | --- |
| `characterId` | string | 前端必传 | 否 | `elio` | 当前收礼角色 ID。后端仍兼容省略,但多角色前端不得省略。 |
| `category` | string | 否 | 否 | `coffee` | 兼容旧的按品类懒加载方式。新版前端首屏不传此字段。 |
### 4.3 请求示例
```bash
curl 'https://proapi.banlv-ai.com/api/payment/gift-products?characterId=elio'
```
### 4.4 成功响应
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"characterId": "elio",
"categories": [
{
"category": "coffee",
"name": "Coffee",
"productCount": 3,
"imageUrl": null
},
{
"category": "flowers",
"name": "Flowers",
"productCount": 2,
"imageUrl": "https://example.com/flowers.jpg"
}
],
"plans": [
{
"planId": "tip_coffee_usd_4_99",
"planName": "Velvet Espresso",
"orderType": "tip",
"tipType": "coffee_small",
"category": "coffee",
"characterId": "elio",
"description": "Buy Elio a small coffee",
"imageUrl": null,
"amountCents": 499,
"currency": "USD",
"autoRenew": false,
"isFirstRechargeOffer": false,
"firstRechargeDiscountPercent": 0,
"promotionType": null
}
]
}
}
```
### 4.5 响应字段
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| `data.characterId` | string | 是 | 本次查询的角色;省略请求参数时为 `null`。 |
| `data.categories` | array | 否 | 当前结果中的品类,顺序与 Manager 商品排序一致。 |
| `categories[].category` | string | 否 | 稳定品类值,只用于关联和筛选。 |
| `categories[].name` | string | 否 | 品类展示名称。 |
| `categories[].productCount` | number(整数) | 否 | 当前品类中的启用商品数量。 |
| `categories[].imageUrl` | string | 是 | 该品类第一张可用商品图;没有图片时为 `null`。 |
| `data.plans` | array | 否 | 当前角色全部启用商品;没有商品时为空数组。 |
| `plans[].planId` | string | 否 | 商品唯一标识,创建订单时必须原样传回。 |
| `plans[].planName` | string | 否 | 商品展示名称。 |
| `plans[].orderType` | string enum | 否 | 礼物固定为 `tip`。 |
| `plans[].tipType` | string | 否 | 支付成功事件使用的礼物细分类型。 |
| `plans[].category` | string | 否 | 所属品类,与 `categories[].category` 对应。 |
| `plans[].characterId` | string | 否 | 收礼角色 ID。 |
| `plans[].description` | string | 否 | 商品说明,可能为空字符串。 |
| `plans[].imageUrl` | string | 是 | 完整公开图片 URL;没有图片时为 `null`。 |
| `plans[].amountCents` | number(整数) | 否 | 展示金额的百分之一;USD 下 `499` 表示 `$4.99`。 |
| `plans[].currency` | string | 否 | 三位大写币种代码,例如 `USD`。 |
| `plans[].autoRenew` | boolean | 否 | 礼物固定为 `false`。 |
| `plans[].isFirstRechargeOffer` | boolean | 否 | 礼物固定为 `false`。 |
| `plans[].firstRechargeDiscountPercent` | number(整数) | 否 | 礼物固定为 `0`。 |
| `plans[].promotionType` | string | 是 | 礼物没有促销时为 `null`。 |
## 五、前端通用品类与商品显示逻辑
### 5.1 建议类型
```ts
interface GiftCategory {
category: string;
name: string;
productCount: number;
imageUrl: string | null;
}
interface GiftProduct {
planId: string;
planName: string;
orderType: "tip";
tipType: string;
category: string;
characterId: string;
description: string;
imageUrl: string | null;
amountCents: number;
currency: string;
autoRenew: false;
}
interface GiftCatalog {
characterId: string | null;
categories: GiftCategory[];
plans: GiftProduct[];
}
```
### 5.2 状态和筛选
```ts
const [catalog, setCatalog] = useState<GiftCatalog | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const visibleProducts = (catalog?.plans ?? []).filter(
product => product.category === selectedCategory,
);
```
- 角色变化时重新请求,并以 `characterId` 作为缓存键,不能复用另一个角色的目录。
- 请求完成后默认选择 `categories[0].category`;深链指定的品类存在时优先选择深链值。
- 切换品类只更新 `selectedCategory`,不请求第二个接口。
- 只有一个品类时可以隐藏品类切换栏。
- 新增未知品类时仍使用同一套通用商品卡片,禁止编写 `if (category === "coffee")` 一类业务分支。
- 商品图片优先级:`product.imageUrl` → 当前 `category.imageUrl` → 通用礼物占位图。
- 商品名称和说明使用 `planName``description`;不要使用前端本地咖啡常量覆盖。
- 价格使用 `Intl.NumberFormat`,金额为 `amountCents / 100`
```ts
const displayPrice = new Intl.NumberFormat("en-US", {
style: "currency",
currency: product.currency,
}).format(product.amountCents / 100);
```
### 5.3 页面状态
| 状态 | 前端行为 |
| --- | --- |
| 加载中 | 显示稳定尺寸的商品骨架,不显示旧角色缓存。 |
| `categories=[]``plans=[]` | 显示“该角色暂时没有可用礼物”,不要回退到写死咖啡。 |
| 某品类没有商品 | 自动切换到第一个有商品的品类;全部为空时显示空状态。 |
| `imageUrl=null` | 使用品类图或通用占位图。 |
| 请求失败 | 显示重试操作,不使用可能过期的价格创建订单。 |
| 角色切换 | 取消旧请求,清空品类、商品、选中商品、订单和 Stripe 状态。 |
### 5.4 兼容接口
- `GET /api/payment/gift-categories` 保留给只需要品类的旧客户端,新版前端主流程不调用。
- `GET /api/payment/gift-products?characterId=elio&category=coffee` 仍支持按品类过滤,适用于未来商品数很大时懒加载。
- `GET /api/payment/tip-plans``/gift-products` 返回同一协议,旧前端继续读取 `data.plans` 不受影响。
- 新增的 `data.characterId``data.categories` 是向后兼容字段。
---
## 六、创建礼物支付订单
### 6.1 请求
```http
POST /api/payment/create-order
```
- 完整地址:`https://proapi.banlv-ai.com/api/payment/create-order`
- 登录鉴权:需要
- Header`Authorization: Bearer <USER_TOKEN>`
- Content-Type`application/json`
### 6.2 请求字段
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
| --- | --- | --- | --- | --- | --- |
| `planId` | string | 是 | 否 | `tip_coffee_usd_4_99` | 必须使用本次商品接口返回的原始值。 |
| `payChannel` | enum string | 是 | 否 | `stripe` | 可选值:`stripe``ezpay`。 |
| `autoRenew` | boolean | 是 | 否 | `false` | 礼物是一次性付款,固定发送 `false`。 |
| `recipientCharacterId` | string | 否 | 否 | `elio` | 可省略;省略时使用商品所属角色。传入其他角色会被拒绝。 |
### 6.3 请求示例
```bash
curl -X POST 'https://proapi.banlv-ai.com/api/payment/create-order' \
-H 'Authorization: Bearer <USER_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"planId": "tip_coffee_usd_4_99",
"payChannel": "stripe",
"autoRenew": false,
"recipientCharacterId": "elio"
}'
```
### 6.4 Stripe 成功响应
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"orderId": "pay_xxx",
"payParams": {
"clientSecret": "<STRIPE_CLIENT_SECRET>",
"provider": "stripe",
"automaticRenewal": false,
"firstChargeAmountCents": 499,
"renewalAmountCents": null
},
"expiresAt": "2026-07-20T10:30:00+00:00",
"expiresInSeconds": 1800
}
}
```
### 6.5 Stripe 前端处理要求
- 使用 `data.payParams.clientSecret` 挂载 Stripe Payment Element。
- `firstChargeAmountCents` 应与所选商品的 `amountCents` 一致,可用于提交支付前的防错校验。
- 用户切换商品或重新创建订单后,必须销毁旧 Payment Element,并使用新的 `orderId + clientSecret` 重新挂载。
- 不要复用上一件商品的 `clientSecret`,否则页面商品名称、显示价格和实际 PaymentIntent 可能不属于同一订单。
- 礼物不会增加积分或 VIP,不参加首充折扣,也不会自动续费。
EzPay 响应可能在 `payParams` 中返回 `cashierUrl``payData`,前端按对应支付渠道处理。
---
## 七、轮询订单状态
### 7.1 请求
```http
GET /api/payment/order-status?order_id=<ORDER_ID>
```
- 完整地址:`https://proapi.banlv-ai.com/api/payment/order-status`
- 登录鉴权:需要,必须与创建订单的用户一致
- Header`Authorization: Bearer <USER_TOKEN>`
- 查询字段兼容性说明:当前字段名是 `order_id`,不是 `orderId`
### 7.2 请求示例
```bash
curl 'https://proapi.banlv-ai.com/api/payment/order-status?order_id=<ORDER_ID>' \
-H 'Authorization: Bearer <USER_TOKEN>'
```
### 7.3 关键响应字段
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"orderId": "pay_xxx",
"status": "paid",
"orderType": "tip",
"planId": "tip_coffee_usd_4_99",
"creditsAdded": 0
}
}
```
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| `orderId` | string | 否 | 当前订单号。 |
| `status` | enum string | 否 | `pending``paid``failed``expired`。 |
| `orderType` | string | 否 | 礼物订单为 `tip`。 |
| `planId` | string | 是 | 当前订单对应的商品 `planId`。 |
| `creditsAdded` | number(整数) | 否 | 礼物订单固定为 `0`。 |
建议每 3 至 5 秒轮询一次;出现 `paid``failed``expired` 后立即停止。
---
## 八、获取付款后感谢文案
### 8.1 请求
```http
POST /api/payment/tip-message
```
仅在订单状态已经变为 `paid` 后调用。
- 完整地址:`https://proapi.banlv-ai.com/api/payment/tip-message`
- 登录鉴权:需要,必须是订单所属用户
- Header`Authorization: Bearer <USER_TOKEN>`
- Content-Type`application/json`
### 8.2 请求字段
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
| --- | --- | --- | --- | --- | --- |
| `orderId` | string | 是 | 否 | `pay_xxx` | 当前用户已经支付成功的礼物订单号。 |
### 8.3 请求示例
```bash
curl -X POST 'https://proapi.banlv-ai.com/api/payment/tip-message' \
-H 'Authorization: Bearer <USER_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{"orderId":"<PAID_ORDER_ID>"}'
```
### 8.4 成功响应
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"orderId": "pay_xxx",
"characterId": "elio",
"planId": "tip_coffee_usd_4_99",
"productName": "Velvet Espresso",
"tipCount": 1,
"poolIndex": 37,
"message": "This is the 1st time you've sent me \"Velvet Espresso\". You have a knack for making me smile."
}
}
```
### 8.5 响应字段
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| `orderId` | string | 否 | 已支付订单号。 |
| `characterId` | string | 否 | 收礼角色 ID。 |
| `planId` | string | 否 | 本次实际支付商品的 `planId`。 |
| `productName` | string | 否 | 本次实际支付商品名称。 |
| `tipCount` | number(整数) | 否 | 当前用户对同一 `planId` 的成功支付次数。 |
| `poolIndex` | number(整数) | 否 | 本次选中的预生成文案下标,范围为 `0``99`。 |
| `message` | string | 否 | 可直接展示的完整英文文案。 |
每个角色在 Manager 中预先保存正好 100 条英文感谢语。后端使用 `characterId + orderId` 稳定选择一条,因此同一个 `orderId` 重复请求时,`poolIndex``message` 保持一致。该接口不会在请求时调用 AI。
---
## 十、前端验收步骤
1. 在预发请求 Elio 的品类,确认页面根据接口返回结果渲染,没有写死咖啡品类。
2. 请求选中品类的商品,确认商品名称、`planId`、图片、`amountCents` 和币种全部来自接口。
3. 分别选择 `$4.99``$9.99` 商品,确认创建订单请求发送的是对应商品自己的 `planId`
4. 创建订单后校验 `payParams.firstChargeAmountCents === product.amountCents`;不一致时阻止支付并重新创建订单。
5. 切换商品后确认旧 Stripe Payment Element 已销毁,新的组件使用新 `clientSecret`
6. 使用预发测试账号完成一笔允许的支付,轮询状态直到 `paid`
7. 连续两次调用付款后文案接口,确认同一订单返回内容完全一致。
8. 再次购买同一商品,确认 `tipCount` 增加 1。
9. 确认礼物付款后没有增加积分、VIP 或自动续费。
## 十一、注意事项
- 前端不要缓存并跨商品复用 `orderId``clientSecret``payParams`
- `planId` 是商品身份,`amountCents` 是商品价格;二者必须来自同一次商品接口响应。
- 品类或商品为空时展示空状态,不要回退到写死的旧咖啡商品。
- `imageUrl=null` 是合法结果,应显示本地占位图。
- 本接口没有新增数据库迁移,也没有改变现有公开字段命名。
+45 -29
View File
@@ -23,9 +23,10 @@
| 方法 | 路径 | 用途 | | 方法 | 路径 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/api/payment/plans` | VIP 与 Top-up 套餐目录 | | `GET` | `/api/payment/plans` | VIP 与 Top-up 套餐目录 |
| `GET` | `/api/payment/tip-plans` | Tip 套餐目录 | | `GET` | `/api/payment/gift-products?characterId={id}` | 当前角色的完整礼物目录 |
| `POST` | `/api/payment/create-order` | 创建 VIP、Top-up 或 Tip 订单 | | `POST` | `/api/payment/create-order` | 创建 VIP、Top-up 或 Tip 订单 |
| `GET` | `/api/payment/order-status?order_id={id}` | 查询订单状态 | | `GET` | `/api/payment/order-status?order_id={id}` | 查询订单状态 |
| `POST` | `/api/payment/tip-message` | 获取已支付礼物订单的稳定感谢文案 |
所有响应先由通用 envelope 解包,再进入 Payment Schema。页面和状态机不直接读取原始 envelope。 所有响应先由通用 envelope 解包,再进入 Payment Schema。页面和状态机不直接读取原始 envelope。
@@ -65,29 +66,44 @@ promotionType
支付成功后会清除默认套餐缓存、刷新用户权益,并在当前 Actor 中消费首充展示状态。服务端下一次目录响应仍是最终权威结果。 支付成功后会清除默认套餐缓存、刷新用户权益,并在当前 Actor 中消费首充展示状态。服务端下一次目录响应仍是最终权威结果。
### 3.2 Tip 套餐 ### 3.2 Gift Products 目录
```http ```http
GET <API_BASE_URL>/api/payment/tip-plans GET <API_BASE_URL>/api/payment/gift-products?characterId=elio
Authorization: Bearer <TOKEN>
``` ```
```json ```json
{ {
"characterId": "elio",
"categories": [
{
"category": "coffee",
"name": "Coffee",
"productCount": 1,
"imageUrl": null
}
],
"plans": [ "plans": [
{ {
"planId": "tip_coffee_usd_4_99", "planId": "tip_coffee_usd_4_99",
"planName": "Small Coffee", "planName": "Velvet Espresso",
"orderType": "tip",
"tipType": "coffee_small",
"category": "coffee",
"characterId": "elio",
"description": "Buy Elio a small coffee",
"imageUrl": null,
"amountCents": 499, "amountCents": 499,
"currency": "USD" "currency": "USD",
"autoRenew": false
} }
] ]
} }
``` ```
Tip 目录不写入默认套餐缓存。Repository 会把精简 Tip 套餐归一化为 `PaymentPlan`,其中 `orderType="tip"``autoRenew=false`,且不带 VIP、积分或首充权益 目录不需要登录,也不写入默认套餐缓存。前端必须传当前角色 ID,一次读取 `categories` 和全部 `plans`;当前 Tip 页面固定使用第一分类,并按商品原始顺序默认选择第一件商品,不展示分类切换栏
Tip 页面只允许选择本地 Coffee Tier 能映射到的后端 `planId`。后端未返回对应套餐时,该 Tier 标记为不可用,不能使用本地价格创建订单 名称、说明、图片、金额、币种和 `planId` 全部来自同一条商品数据。状态机把当前分类商品映射为 `PaymentPlan` 以复用 Checkout 和埋点,同时保留完整 Gift Product 供 UI 展示。目录为空或请求失败时禁止创建订单,不回退到本地固定商品或价格
## 4. 创建订单 ## 4. 创建订单
@@ -180,21 +196,21 @@ Authorization: Bearer <TOKEN>
"status": "paid", "status": "paid",
"orderType": "tip", "orderType": "tip",
"planId": "tip_coffee_usd_4_99", "planId": "tip_coffee_usd_4_99",
"tipCount": 2, "creditsAdded": 0
"thankYouMessage": "You made my day a little sweeter."
} }
``` ```
| 字段 | 前端规则 | | 字段 | 前端规则 |
| --- | --- | | --- | --- |
| `status` | 只接受 `pending``paid``failed` | | `status` | 只接受 `pending``paid``failed``expired` |
| `tipCount` | 仅保留正整数,其他值归一化`null` | | `planId` | 可`null` |
| `thankYouMessage` | trim 后的非空纯文本,其他值归一化为 `null` | | `creditsAdded` | 整数;Tip 固定为 `0` |
状态机创建订单或恢复订单后立即查询一次。`pending` 每 4 秒再次查询,最长持续 5 分钟: 状态机创建订单或恢复订单后立即查询一次。`pending` 每 4 秒再次查询,最长持续 5 分钟:
- `paid`:进入最终成功状态并停止轮询; - `paid`:进入最终成功状态并停止轮询;
- `failed`:进入失败状态并停止轮询; - `failed`:进入失败状态并停止轮询;
- `expired`:进入订单过期状态、销毁旧支付参数并停止轮询;
- 超过 5 分钟:本地标记为失败并显示超时错误; - 超过 5 分钟:本地标记为失败并显示超时错误;
- 查询请求本身失败:进入失败状态,不在当前 Actor 中自动重试。 - 查询请求本身失败:进入失败状态,不在当前 Actor 中自动重试。
@@ -206,7 +222,8 @@ Authorization: Bearer <TOKEN>
orderId orderId
payChannel = ezpay payChannel = ezpay
subscriptionType = vip | topup | tip subscriptionType = vip | topup | tip
tipCoffeeType = small | medium | large(仅 Tip giftCategory(仅 Tip,可空
giftPlanId(仅 Tip,可空)
returnTo = chat | private-room | sidebar(可选) returnTo = chat | private-room | sidebar(可选)
characterSlug(可选) characterSlug(可选)
createdAt createdAt
@@ -216,10 +233,10 @@ createdAt
```text ```text
VIP / Top-up -> /subscription?type=...&payChannel=ezpay&paymentReturn=1 VIP / Top-up -> /subscription?type=...&payChannel=ezpay&paymentReturn=1
Tip -> /characters/{slug}/tip?payChannel=ezpay&paymentReturn=1&coffee_type=... Tip -> /characters/{slug}/tip?category=...&planId=...&payChannel=ezpay&paymentReturn=1
``` ```
恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paidfailed 后清理持久化记录 恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paidfailed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品,不再读取 `coffee_type`
无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-room` 的最终页面行为由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。 无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-room` 的最终页面行为由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
@@ -237,21 +254,19 @@ Subscription 显示成功 Dialog,关闭后根据 `returnTo` 和原角色 slug
## 9. Tip 成功结果 ## 9. Tip 成功结果
paid Tip 订单可以返回 Tip 订单进入 paid 后调用
| 字段 | 含义 | ```http
| --- | --- | POST <API_BASE_URL>/api/payment/tip-message
| `tipCount` | 包含当前订单在内,当前付款身份向当前收款角色的累计成功次数 | Content-Type: application/json
| `thankYouMessage` | 当前订单对应角色的纯文本感谢语 | Authorization: Bearer <TOKEN>
前端展示规则: {"orderId":"pay_xxx"}
```
1. `tipCount=1`:使用首次打赏本地文案; 响应包含 `orderId``characterId``planId``productName``tipCount``poolIndex` 和可直接展示的完整 `message`。前端不再根据次数拼接文案,也不从订单状态读取感谢字段。
2. `tipCount>1` 且感谢语非空:展示英文序数次数和后端感谢语;
3. 字段缺失、非法或不完整:展示通用成功文案,不阻止订单进入 paid;
4. 感谢语按纯文本展示并保留换行,不渲染 HTML。
`tipCount``thankYouMessage` 必须对同一个 `orderId` 保持稳定。pending、failed 和非 Tip 订单应返回 `null`,前端也会将无效值归一化为 `null` Tip 成功页在文案加载期间立即确认支付成功;接口成功后按纯文本直接展示 `message`。接口失败时展示本地通用感谢语和 Retry,重试只重新请求 Tip Message,不重复轮询订单或创建订单
## 10. Provider 与状态边界 ## 10. Provider 与状态边界
@@ -271,6 +286,7 @@ Payment 协议相关变更至少验证:
4. `src/lib/payment/__tests__` 4. `src/lib/payment/__tests__`
5. `src/app/_hooks/__tests__` 中的 Payment 流程测试; 5. `src/app/_hooks/__tests__` 中的 Payment 流程测试;
6. `src/app/subscription/__tests__``src/app/tip/__tests__` 6. `src/app/subscription/__tests__``src/app/tip/__tests__`
7. Stripe、Ezpay、paid、failed、timeout 和回跳恢复路径; 7. Stripe、Ezpay、paid、failed、expired、timeout 和回跳恢复路径;
8. Tip 创建订单携带当前角色 IDVIP/Top-up 不携带; 8. Tip 创建订单携带当前角色 IDVIP/Top-up 不携带;
9. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。 9. Tip Message 成功、失败和重试不重复创建订单;
10. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。
+7 -1
View File
@@ -10,5 +10,11 @@ export const tipPaymentPlansResponse = { plans: [
] }; ] };
export const paymentOrderId = "order_e2e_vip_monthly"; export const paymentOrderId = "order_e2e_vip_monthly";
export const createPaymentOrderResponse = { orderId: paymentOrderId, payParams: { provider: "stripe", clientSecret: "pi_e2e_secret_mock" } }; export const createPaymentOrderResponse = { orderId: paymentOrderId, payParams: { provider: "stripe", clientSecret: "pi_e2e_secret_mock" } };
export const paidPaymentOrderStatusResponse = { orderId: paymentOrderId, status: "paid", orderType: "vip_monthly", planId: "vip_monthly" }; export const paidPaymentOrderStatusResponse = {
orderId: paymentOrderId,
status: "paid",
orderType: "vip_monthly",
planId: "vip_monthly",
creditsAdded: 0,
};
export const vipStatusResponse = { isVip: false, expiresAt: null }; export const vipStatusResponse = { isVip: false, expiresAt: null };
@@ -38,4 +38,16 @@ describe("shouldInspectPendingPaymentOrder", () => {
}), }),
).toBe(false); ).toBe(false);
}); });
it("allows stale expired orders to be cleaned up", () => {
expect(
shouldInspectPendingPaymentOrder({
currentOrderId: "order-expired",
isPaid: false,
isPollingOrder: false,
shouldResumePendingOrder: false,
status: "expired",
}),
).toBe(true);
});
}); });
+10 -6
View File
@@ -12,7 +12,6 @@ import { behaviorAnalytics } from "@/lib/analytics";
import type { import type {
PendingPaymentReturnTo, PendingPaymentReturnTo,
PendingPaymentSubscriptionType, PendingPaymentSubscriptionType,
PendingPaymentTipCoffeeType,
} from "@/lib/payment/pending_payment_order"; } from "@/lib/payment/pending_payment_order";
import type { import type {
PaymentContextState, PaymentContextState,
@@ -39,7 +38,8 @@ export interface UsePaymentLaunchFlowInput {
returnTo?: PendingPaymentReturnTo; returnTo?: PendingPaymentReturnTo;
characterSlug?: string; characterSlug?: string;
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType; giftCategory?: string | null;
giftPlanId?: string | null;
} }
export interface PaymentLaunchFlow { export interface PaymentLaunchFlow {
@@ -110,7 +110,8 @@ export function usePaymentLaunchFlow({
returnTo, returnTo,
characterSlug, characterSlug,
subscriptionType, subscriptionType,
tipCoffeeType, giftCategory,
giftPlanId,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow { }: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0); const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
@@ -153,7 +154,8 @@ export function usePaymentLaunchFlow({
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrl, paymentUrl,
subscriptionType, subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}), giftCategory,
giftPlanId,
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
...(characterSlug ? { characterSlug } : {}), ...(characterSlug ? { characterSlug } : {}),
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl), onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
@@ -197,7 +199,8 @@ export function usePaymentLaunchFlow({
paymentDispatch, paymentDispatch,
returnTo, returnTo,
subscriptionType, subscriptionType,
tipCoffeeType, giftCategory,
giftPlanId,
]); ]);
const shouldShowStripeDialog = const shouldShowStripeDialog =
@@ -238,7 +241,8 @@ export function usePaymentLaunchFlow({
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl, paymentUrl: ezpayPaymentUrl,
subscriptionType, subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}), giftCategory,
giftPlanId,
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
...(characterSlug ? { characterSlug } : {}), ...(characterSlug ? { characterSlug } : {}),
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl), onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
+22 -21
View File
@@ -19,6 +19,9 @@ export interface UsePaymentRouteFlowInput {
initialPayChannel: PayChannel; initialPayChannel: PayChannel;
paymentType: PendingPaymentSubscriptionType; paymentType: PendingPaymentSubscriptionType;
shouldResumePendingOrder: boolean; shouldResumePendingOrder: boolean;
characterId?: string;
initialCategory?: string | null;
initialPlanId?: string | null;
} }
export interface PaymentRouteFlow { export interface PaymentRouteFlow {
@@ -35,10 +38,19 @@ export function usePaymentRouteFlow({
initialPayChannel, initialPayChannel,
paymentType, paymentType,
shouldResumePendingOrder, shouldResumePendingOrder,
characterId,
initialCategory = null,
initialPlanId = null,
}: UsePaymentRouteFlowInput): PaymentRouteFlow { }: UsePaymentRouteFlowInput): PaymentRouteFlow {
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const initialPayChannelAppliedRef = useRef(false); const initializedCatalogKeyRef = useRef<string | null>(null);
const catalogKey = [
catalog,
characterId ?? "",
initialCategory ?? "",
initialPlanId ?? "",
].join(":");
usePendingPaymentOrderLifecycle({ usePendingPaymentOrderLifecycle({
payment, payment,
@@ -48,34 +60,23 @@ export function usePaymentRouteFlow({
}); });
useEffect(() => { useEffect(() => {
if (payment.status === "idle") { if (initializedCatalogKeyRef.current === catalogKey) return;
initialPayChannelAppliedRef.current = true; initializedCatalogKeyRef.current = catalogKey;
paymentDispatch({ paymentDispatch({
type: "PaymentInit", type: "PaymentInit",
catalog, catalog,
payChannel: initialPayChannel, payChannel: initialPayChannel,
}); ...(characterId ? { characterId } : {}),
return; ...(initialCategory ? { category: initialCategory } : {}),
} ...(initialPlanId ? { planId: initialPlanId } : {}),
if (
initialPayChannelAppliedRef.current ||
payment.status !== "ready"
) {
return;
}
initialPayChannelAppliedRef.current = true;
if (payment.payChannel === initialPayChannel) return;
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel: initialPayChannel,
}); });
}, [ }, [
catalog, catalog,
catalogKey,
characterId,
initialCategory,
initialPlanId,
initialPayChannel, initialPayChannel,
payment.payChannel,
payment.status,
paymentDispatch, paymentDispatch,
]); ]);
@@ -37,7 +37,7 @@ export function shouldInspectPendingPaymentOrder({
return ( return (
status === "ready" || status === "ready" ||
(!shouldResumePendingOrder && (!shouldResumePendingOrder &&
(isPollingOrder || isPaid || status === "failed")) (isPollingOrder || isPaid || status === "failed" || status === "expired"))
); );
} }
@@ -74,7 +74,8 @@ export function usePendingPaymentOrderLifecycle({
payment.currentOrderId === result.data.orderId && payment.currentOrderId === result.data.orderId &&
(payment.isPollingOrder || (payment.isPollingOrder ||
payment.isPaid || payment.isPaid ||
payment.status === "failed") payment.status === "failed" ||
payment.status === "expired")
) { ) {
paymentDispatch({ type: "PaymentReset" }); paymentDispatch({ type: "PaymentReset" });
} }
@@ -108,7 +109,13 @@ export function usePendingPaymentOrderLifecycle({
useEffect(() => { useEffect(() => {
if (!payment.currentOrderId) return; if (!payment.currentOrderId) return;
if (!payment.isPaid && payment.status !== "failed") return; if (
!payment.isPaid &&
payment.status !== "failed" &&
payment.status !== "expired"
) {
return;
}
void clearPendingPaymentOrder(); void clearPendingPaymentOrder();
}, [payment.currentOrderId, payment.isPaid, payment.status]); }, [payment.currentOrderId, payment.isPaid, payment.status]);
@@ -4,10 +4,10 @@ import {
type PaymentSearchParams, type PaymentSearchParams,
} from "@/lib/payment/payment_search_params"; } from "@/lib/payment/payment_search_params";
import { import {
DEFAULT_TIP_COFFEE_TYPE, normalizeTipGiftParam,
resolveTipCoffeeType, TIP_GIFT_CATEGORY_PARAM,
TIP_COFFEE_TYPE_PARAM, TIP_GIFT_PLAN_ID_PARAM,
} from "@/lib/tip/tip_coffee"; } from "@/lib/tip/tip_gift";
import { TipScreen } from "@/app/tip/tip-screen"; import { TipScreen } from "@/app/tip/tip-screen";
export default async function CharacterTipPage({ export default async function CharacterTipPage({
@@ -17,14 +17,17 @@ export default async function CharacterTipPage({
}) { }) {
const query = await searchParams; const query = await searchParams;
const paymentReturn = parsePaymentReturnSearchParams(query); const paymentReturn = parsePaymentReturnSearchParams(query);
const coffeeType = const initialCategory = normalizeTipGiftParam(
resolveTipCoffeeType( getFirstPaymentSearchParam(query[TIP_GIFT_CATEGORY_PARAM]),
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]), );
) ?? DEFAULT_TIP_COFFEE_TYPE; const initialPlanId = normalizeTipGiftParam(
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
);
return ( return (
<TipScreen <TipScreen
coffeeType={coffeeType} initialCategory={initialCategory}
initialPlanId={initialPlanId}
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder} shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
initialPayChannel={paymentReturn.initialPayChannel} initialPayChannel={paymentReturn.initialPayChannel}
/> />
@@ -1,96 +0,0 @@
/* @vitest-environment jsdom */
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
import {
TipCoffeeTierSelector,
type TipCoffeeTierItem,
} from "../tip-coffee-tier-selector";
const items: readonly TipCoffeeTierItem[] = [
{
type: "small",
displayName: "Velvet Espresso",
priceLabel: "US$ 4.99",
unavailable: false,
},
{
type: "medium",
displayName: "Gilded Heart",
priceLabel: "US$ 9.99",
unavailable: false,
},
{
type: "large",
displayName: "Crown Blossom",
priceLabel: "US$ 19.99",
unavailable: false,
},
];
function Harness({ tierItems = items }: { tierItems?: readonly TipCoffeeTierItem[] }) {
const [selectedType, setSelectedType] =
useState<TipCoffeeType>("medium");
return (
<TipCoffeeTierSelector
disabled={false}
items={tierItems}
onChange={setSelectedType}
selectedType={selectedType}
/>
);
}
describe("TipCoffeeTierSelector", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders every luxury tier and selects medium by default", () => {
act(() => root.render(<Harness />));
expect(container.textContent).toContain("Velvet Espresso");
expect(container.textContent).toContain("Gilded Heart");
expect(container.textContent).toContain("Crown Blossom");
expect(
container.querySelector<HTMLInputElement>('input[value="medium"]')
?.checked,
).toBe(true);
});
it("switches to an available tier and keeps unavailable tiers disabled", () => {
const tierItems = items.map((item) =>
item.type === "large" ? { ...item, unavailable: true } : item,
);
act(() => root.render(<Harness tierItems={tierItems} />));
const small = container.querySelector<HTMLInputElement>(
'input[value="small"]',
);
const large = container.querySelector<HTMLInputElement>(
'input[value="large"]',
);
act(() => small?.click());
expect(small?.checked).toBe(true);
expect(large?.disabled).toBe(true);
expect(container.textContent).toContain("Unavailable");
});
});
@@ -0,0 +1,92 @@
/* @vitest-environment jsdom */
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
GiftProductSchema,
type GiftProduct,
} from "@/data/schemas/payment";
import { TipGiftProductSelector } from "../tip-gift-product-selector";
const products: readonly GiftProduct[] = [
makeProduct("gift_small", "Velvet Espresso", 499),
makeProduct("gift_medium", "Golden Reserve", 999),
makeProduct("gift_large", "Imperial Grand Cru", 1999),
];
function Harness() {
const [selectedPlanId, setSelectedPlanId] = useState("gift_small");
return (
<TipGiftProductSelector
disabled={false}
products={products}
onChange={setSelectedPlanId}
selectedPlanId={selectedPlanId}
/>
);
}
describe("TipGiftProductSelector", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders backend products and selects the first product", () => {
act(() => root.render(<Harness />));
expect(container.textContent).toContain("Velvet Espresso");
expect(container.textContent).toContain("Golden Reserve");
expect(container.textContent).toContain("Imperial Grand Cru");
expect(
container.querySelector<HTMLInputElement>('input[value="gift_small"]')
?.checked,
).toBe(true);
});
it("switches the selected backend plan id", () => {
act(() => root.render(<Harness />));
const medium = container.querySelector<HTMLInputElement>(
'input[value="gift_medium"]',
);
act(() => medium?.click());
expect(medium?.checked).toBe(true);
});
});
function makeProduct(
planId: string,
planName: string,
amountCents: number,
): GiftProduct {
return GiftProductSchema.parse({
planId,
planName,
orderType: "tip",
tipType: planId,
category: "coffee",
characterId: "elio",
description: `${planName} description`,
imageUrl: null,
amountCents,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
});
}
@@ -5,7 +5,10 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
GiftCategorySchema,
GiftProductSchema,
PaymentPlanSchema, PaymentPlanSchema,
TipMessageResponseSchema,
type PaymentPlan, type PaymentPlan,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import type { PaymentContextState } from "@/stores/payment/payment-context"; import type { PaymentContextState } from "@/stores/payment/payment-context";
@@ -44,6 +47,7 @@ vi.mock("@/lib/analytics", () => ({
vi.mock("@/providers/character-provider", () => ({ vi.mock("@/providers/character-provider", () => ({
useActiveCharacter: () => ({ useActiveCharacter: () => ({
id: "maya-tan", id: "maya-tan",
slug: "maya",
displayName: "Maya Tan", displayName: "Maya Tan",
assets: { assets: {
avatar: "/images/avatar/maya.png", avatar: "/images/avatar/maya.png",
@@ -51,7 +55,7 @@ vi.mock("@/providers/character-provider", () => ({
}, },
copy: { copy: {
tipHeader: "Tip Maya", tipHeader: "Tip Maya",
tipTitle: "Buy Maya a coffee", tipTitle: "Send Maya a gift",
}, },
}), }),
useActiveCharacterRoutes: () => ({ useActiveCharacterRoutes: () => ({
@@ -80,8 +84,11 @@ vi.mock("../tip-checkout-button", () => ({
</button> </button>
), ),
})); }));
vi.mock("../tip-coffee-tier-selector", () => ({ vi.mock("../tip-gift-product-selector", () => ({
TipCoffeeTierSelector: () => null, TipGiftProductSelector: () => null,
}));
vi.mock("../tip-product-image", () => ({
TipProductImage: ({ alt }: { alt: string }) => <span>{alt}</span>,
})); }));
vi.mock("../use-tip-support-prompt", () => ({ vi.mock("../use-tip-support-prompt", () => ({
useTipSupportPrompt: () => ({ useTipSupportPrompt: () => ({
@@ -92,21 +99,41 @@ vi.mock("../use-tip-support-prompt", () => ({
import { TipScreen } from "../tip-screen"; import { TipScreen } from "../tip-screen";
const mediumPlan: PaymentPlan = PaymentPlanSchema.parse({ const giftCategory = GiftCategorySchema.parse({
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
});
const giftProduct = GiftProductSchema.parse({
planId: "tip_coffee_usd_9_99", planId: "tip_coffee_usd_9_99",
planName: "Gilded Heart", planName: "Golden Reserve",
orderType: "tip",
tipType: "coffee_medium",
category: "coffee",
characterId: "maya-tan",
description: "A warm reserve coffee",
imageUrl: null,
amountCents: 999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
});
const giftPlan: PaymentPlan = PaymentPlanSchema.parse({
planId: giftProduct.planId,
planName: giftProduct.planName,
orderType: "tip", orderType: "tip",
vipDays: null, vipDays: null,
dolAmount: null, dolAmount: null,
creditBalance: 0, creditBalance: 0,
amountCents: 999, amountCents: giftProduct.amountCents,
originalAmountCents: null, originalAmountCents: null,
dailyPriceCents: null, dailyPriceCents: null,
currency: "USD", currency: giftProduct.currency,
isFirstRechargeOffer: false,
mostPopular: false,
firstRechargeDiscountPercent: null,
promotionType: null,
}); });
describe("TipScreen checkout", () => { describe("TipScreen checkout", () => {
@@ -129,16 +156,16 @@ describe("TipScreen checkout", () => {
container.remove(); container.remove();
}); });
it("creates a character-attributed order without an AuthProvider", () => { it("creates a dynamic character-attributed gift order", () => {
renderScreen(); renderScreen();
const checkout = getCheckoutButton(); const checkout = getCheckoutButton();
expect(container.textContent).toContain("A warm coffee prompt."); expect(container.textContent).toContain("Golden Reserve");
expect(checkout.disabled).toBe(false); expect(checkout.disabled).toBe(false);
act(() => checkout.click()); act(() => checkout.click());
expect(mocks.planClick).toHaveBeenCalledWith( expect(mocks.planClick).toHaveBeenCalledWith(
mediumPlan, giftPlan,
expect.objectContaining({ entryPoint: "tip_page" }), expect.objectContaining({ entryPoint: "tip_page" }),
); );
expect(mocks.paymentDispatch).toHaveBeenCalledWith({ expect(mocks.paymentDispatch).toHaveBeenCalledWith({
@@ -148,84 +175,60 @@ describe("TipScreen checkout", () => {
}); });
it.each([ it.each([
["plans are loading", { isLoadingPlans: true }], ["catalog is loading", { isLoadingPlans: true }],
["the selected plan is missing", { plans: [], selectedPlanId: "" }], [
"the selected product is missing",
{ plans: [], giftProducts: [], selectedPlanId: "" },
],
["an order is being created", { isCreatingOrder: true }], ["an order is being created", { isCreatingOrder: true }],
["an order is being polled", { isPollingOrder: true }], ["an order is being polled", { isPollingOrder: true }],
] as const)("disables checkout when %s", (_label, overrides) => { ] as const)("disables checkout when %s", (_label, overrides) => {
mocks.payment = makePaymentState(overrides); mocks.payment = makePaymentState(overrides);
renderScreen(); renderScreen();
expect(getCheckoutButton().disabled).toBe(true); expect(getCheckoutButton().disabled).toBe(true);
}); });
it("replaces checkout with the first Tip success experience", () => { it("shows the complete backend Tip message after payment", () => {
mocks.payment = makePaymentState({ mocks.payment = makePaymentState({
status: "paid", status: "paid",
isPaid: true, isPaid: true,
orderStatus: "paid", orderStatus: "paid",
tipCount: 1, tipMessage: TipMessageResponseSchema.parse({
thankYouMessage: "A backend message that is not used for first Tip.", orderId: "pay_xxx",
characterId: "maya-tan",
planId: giftProduct.planId,
productName: giftProduct.planName,
tipCount: 2,
poolIndex: 17,
message: "This complete message came directly from the backend.",
}),
}); });
renderScreen(); renderScreen();
expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull(); expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull();
expect(container.textContent).toContain( expect(container.textContent).toContain(
"Did you really just buy me a coffee?", "This complete message came directly from the backend.",
);
expect(container.textContent).toContain("That honestly made me smile.");
expect(container.textContent).toContain(
"Thank you. I'll definitely think of you while I enjoy it.",
); );
expect(document.activeElement?.id).toBe("tip-success-title"); expect(document.activeElement?.id).toBe("tip-success-title");
const sendAgain = getButton("Send another coffee"); act(() => getButton("Send another gift").click());
act(() => sendAgain.click()); expect(mocks.paymentDispatch).toHaveBeenCalledWith({ type: "PaymentReset" });
});
it("keeps paid success visible and retries only a failed message", () => {
mocks.payment = makePaymentState({
status: "tipMessageFailed",
isPaid: true,
orderStatus: "paid",
tipMessageError: "message unavailable",
});
renderScreen();
expect(container.textContent).toContain("Thank you. Your gift made me smile.");
act(() => getButton("Retry message").click());
expect(mocks.paymentDispatch).toHaveBeenCalledWith({ expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentReset", type: "PaymentTipMessageRetryRequested",
}); });
expect(
container
.querySelector<HTMLAnchorElement>(
'[data-analytics-key="tip.success_back_to_splash"]',
)
?.getAttribute("href"),
).toBe("/characters/maya/splash");
});
it("shows the repeat Tip count and backend thank-you message", () => {
mocks.payment = makePaymentState({
status: "paid",
isPaid: true,
orderStatus: "paid",
tipCount: 22,
thankYouMessage: "You always make my day sweeter.",
});
renderScreen();
expect(container.textContent).toContain(
"This is the 22nd coffee you've treated me to.",
);
expect(container.textContent).toContain(
"You always make my day sweeter.",
);
});
it("uses generic success copy when Tip metadata is incomplete", () => {
mocks.payment = makePaymentState({
status: "paid",
isPaid: true,
orderStatus: "paid",
tipCount: 2,
thankYouMessage: null,
});
renderScreen();
expect(container.textContent).toContain(
"Thank you. Your coffee made me smile.",
);
expect(container.textContent).not.toContain("2nd coffee");
}); });
function renderScreen(): void { function renderScreen(): void {
@@ -254,20 +257,25 @@ function makePaymentState(
): PaymentContextState { ): PaymentContextState {
return { return {
status: "ready", status: "ready",
plans: [mediumPlan], planCatalog: "tip",
plans: [giftPlan],
giftCategories: [giftCategory],
giftProducts: [giftProduct],
selectedGiftCategory: giftCategory.category,
isFirstRecharge: false, isFirstRecharge: false,
selectedPlanId: mediumPlan.planId, selectedPlanId: giftPlan.planId,
payChannel: "stripe", payChannel: "stripe",
autoRenew: false, autoRenew: false,
agreed: true, agreed: true,
currentOrderId: null, currentOrderId: null,
payParams: null, payParams: null,
orderStatus: null, orderStatus: null,
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
errorMessage: null, errorMessage: null,
launchNonce: 0, launchNonce: 0,
isLoadingPlans: false, isLoadingPlans: false,
isLoadingTipMessage: false,
isCreatingOrder: false, isCreatingOrder: false,
isPollingOrder: false, isPollingOrder: false,
isPaid: false, isPaid: false,
@@ -1,108 +1,60 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment"; import {
import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan"; GiftCategorySchema,
GiftProductSchema,
} from "@/data/schemas/payment";
import { import {
findTipCoffeePlan, formatGiftPrice,
formatEnglishOrdinal, getGiftImageSources,
formatTipPrice, TIP_GIFT_PLACEHOLDER_IMAGE,
resolveTipSuccessCopy,
} from "../tip-screen.helpers"; } from "../tip-screen.helpers";
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan { const category = GiftCategorySchema.parse({
return PaymentPlanSchema.parse({ category: "coffee",
planId: "coins_100", name: "Coffee",
planName: "Coins", productCount: 1,
orderType: "dol", imageUrl: "https://cdn.example.com/category.jpg",
vipDays: null, });
dolAmount: 100,
creditBalance: 100, const product = GiftProductSchema.parse({
amountCents: 990, planId: "gift_1",
originalAmountCents: null, planName: "Golden Reserve",
dailyPriceCents: null, orderType: "tip",
tipType: "coffee_medium",
category: "coffee",
characterId: "elio",
description: "A warm coffee",
imageUrl: "https://cdn.example.com/product.jpg",
amountCents: 999,
currency: "USD", currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false, isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null, firstRechargeDiscountPercent: 0,
promotionType: null, promotionType: null,
...input, });
});
}
describe("tip screen helpers", () => { describe("tip screen helpers", () => {
it("matches the official small coffee plan", () => { it("formats prices from backend amount and currency", () => {
const plan = makePlan({ expect(formatGiftPrice(999, "usd")).toBe("$9.99");
planId: "tip_coffee_usd_4_99", expect(formatGiftPrice(500, "PHP")).toContain("5.00");
}); });
expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan); it("orders product, category, and local image fallbacks", () => {
expect(getGiftImageSources(product, category)).toEqual([
"https://cdn.example.com/product.jpg",
"https://cdn.example.com/category.jpg",
TIP_GIFT_PLACEHOLDER_IMAGE,
]);
}); });
it("matches medium and large coffee plans independently", () => { it("always retains the local placeholder", () => {
const medium = makePlan({
planId: "tip_coffee_usd_9_99",
orderType: "tip",
amountCents: 999,
});
const large = makePlan({
planId: "tip_coffee_usd_19_99",
orderType: "tip",
amountCents: 1999,
});
expect(findTipCoffeePlan([large, medium], "medium")).toBe(medium);
expect(findTipCoffeePlan([medium, large], "large")).toBe(large);
});
it("does not infer a coffee tier from order type or amount", () => {
const ambiguousPlan = makePlan({
planId: "legacy_coffee",
orderType: "tip",
amountCents: 999,
});
expect(findTipCoffeePlan([ambiguousPlan], "medium")).toBeNull();
});
it("formats USD prices with the product-style label", () => {
expect( expect(
formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }), "small"), getGiftImageSources(
).toBe("US$ 5"); GiftProductSchema.parse({ ...product, imageUrl: null }),
}); GiftCategorySchema.parse({ ...category, imageUrl: null }),
),
it("uses the selected coffee price when its plan is unavailable", () => { ).toEqual([TIP_GIFT_PLACEHOLDER_IMAGE]);
expect(formatTipPrice(null, "small")).toBe("US$ 4.99");
expect(formatTipPrice(null, "medium")).toBe("US$ 9.99");
expect(formatTipPrice(null, "large")).toBe("US$ 19.99");
});
it.each([
[2, "2nd"],
[3, "3rd"],
[11, "11th"],
[12, "12th"],
[13, "13th"],
[21, "21st"],
[22, "22nd"],
])("formats %i as the English ordinal %s", (value, expected) => {
expect(formatEnglishOrdinal(value)).toBe(expected);
});
it("resolves first, repeat, and fallback success copy", () => {
expect(resolveTipSuccessCopy(1, "Ignored for the first Tip")).toEqual({
title: "Did you really just buy me a coffee?",
body: [
"That honestly made me smile.",
"Thank you. I'll definitely think of you while I enjoy it.",
],
});
expect(resolveTipSuccessCopy(22, "You made my day.")).toEqual({
title: "This is the 22nd coffee you've treated me to.",
body: ["You made my day."],
});
expect(resolveTipSuccessCopy(2, null)).toEqual({
title: "Thank you. Your coffee made me smile.",
body: [],
});
}); });
}); });
+6 -4
View File
@@ -2,7 +2,6 @@
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs"; import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
import { useActiveCharacter } from "@/providers/character-provider"; import { useActiveCharacter } from "@/providers/character-provider";
import { import {
usePaymentDispatch, usePaymentDispatch,
@@ -15,14 +14,16 @@ import styles from "./tip-screen.module.css";
const log = new Logger("TipCheckoutButton"); const log = new Logger("TipCheckoutButton");
export interface TipCheckoutButtonProps { export interface TipCheckoutButtonProps {
coffeeType: TipCoffeeType; giftCategory: string | null;
giftPlanId: string | null;
disabled?: boolean; disabled?: boolean;
onOrder: () => void; onOrder: () => void;
returnPath: string; returnPath: string;
} }
export function TipCheckoutButton({ export function TipCheckoutButton({
coffeeType, giftCategory,
giftPlanId,
disabled = false, disabled = false,
onOrder, onOrder,
returnPath, returnPath,
@@ -36,7 +37,8 @@ export function TipCheckoutButton({
payment, payment,
paymentDispatch, paymentDispatch,
subscriptionType: "tip", subscriptionType: "tip",
tipCoffeeType: coffeeType, giftCategory,
giftPlanId,
characterSlug: character.slug, characterSlug: character.slug,
}); });
-70
View File
@@ -1,70 +0,0 @@
import { Check } from "lucide-react";
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
import styles from "./tip-screen.module.css";
export interface TipCoffeeTierItem {
type: TipCoffeeType;
displayName: string;
priceLabel: string;
unavailable: boolean;
}
interface TipCoffeeTierSelectorProps {
disabled: boolean;
items: readonly TipCoffeeTierItem[];
onChange: (type: TipCoffeeType) => void;
selectedType: TipCoffeeType;
}
export function TipCoffeeTierSelector({
disabled,
items,
onChange,
selectedType,
}: TipCoffeeTierSelectorProps) {
return (
<fieldset className={styles.tierSelector} disabled={disabled}>
<legend className={styles.tierLegend}>Choose your coffee</legend>
<div className={styles.tierList}>
{items.map((item) => {
const isSelected = item.type === selectedType;
return (
<label
key={item.type}
className={styles.tierOption}
data-selected={isSelected ? "true" : "false"}
data-unavailable={item.unavailable ? "true" : "false"}
>
<input
type="radio"
name="tip-coffee-tier"
value={item.type}
checked={isSelected}
disabled={item.unavailable}
className={styles.tierInput}
data-analytics-key={`tip.select_${item.type}`}
data-analytics-label={`Select ${item.displayName}`}
onChange={() => onChange(item.type)}
/>
<span className={styles.tierDetails}>
<span className={styles.tierName}>{item.displayName}</span>
</span>
<span className={styles.tierPriceBlock}>
<span className={styles.tierPrice}>{item.priceLabel}</span>
{item.unavailable ? (
<span className={styles.tierUnavailable}>Unavailable</span>
) : null}
</span>
<span className={styles.tierCheck} aria-hidden="true">
{isSelected ? <Check size={16} strokeWidth={3} /> : null}
</span>
</label>
);
})}
</div>
</fieldset>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Check } from "lucide-react";
import type { GiftProduct } from "@/data/schemas/payment";
import { formatGiftPrice } from "./tip-screen.helpers";
import styles from "./tip-screen.module.css";
interface TipGiftProductSelectorProps {
disabled: boolean;
products: readonly GiftProduct[];
onChange: (planId: string) => void;
selectedPlanId: string;
}
export function TipGiftProductSelector({
disabled,
products,
onChange,
selectedPlanId,
}: TipGiftProductSelectorProps) {
return (
<fieldset className={styles.tierSelector} disabled={disabled}>
<legend className={styles.visuallyHidden}>Choose a gift</legend>
<div className={styles.tierList}>
{products.map((product) => {
const isSelected = product.planId === selectedPlanId;
return (
<label
key={product.planId}
className={styles.tierOption}
data-selected={isSelected ? "true" : "false"}
>
<input
type="radio"
name="tip-gift-product"
value={product.planId}
checked={isSelected}
className={styles.tierInput}
data-analytics-key="tip.select_product"
data-analytics-label={`Select ${product.planName}`}
onChange={() => onChange(product.planId)}
/>
<span className={styles.tierDetails}>
<span className={styles.tierName}>{product.planName}</span>
{product.description ? (
<span className={styles.tierDescription}>
{product.description}
</span>
) : null}
</span>
<span className={styles.tierPriceBlock}>
<span className={styles.tierPrice}>
{formatGiftPrice(product.amountCents, product.currency)}
</span>
</span>
<span className={styles.tierCheck} aria-hidden="true">
{isSelected ? <Check size={16} strokeWidth={3} /> : null}
</span>
</label>
);
})}
</div>
</fieldset>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
interface TipProductImageProps {
alt: string;
className: string;
priority?: boolean;
sources: readonly string[];
}
export function TipProductImage({
alt,
className,
priority = false,
sources,
}: TipProductImageProps) {
const [sourceIndex, setSourceIndex] = useState(0);
const source = sources[sourceIndex] ?? sources[sources.length - 1];
if (!source) return null;
return (
// Gift hosts are managed by the backend and cannot be safely enumerated in Next remotePatterns.
// eslint-disable-next-line @next/next/no-img-element
<img
src={source}
alt={alt}
className={className}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : "auto"}
onError={() => {
if (sourceIndex < sources.length - 1) {
setSourceIndex((index) => index + 1);
}
}}
/>
);
}
+29 -72
View File
@@ -1,79 +1,36 @@
import type { PaymentPlan } from "@/data/schemas/payment"; import type {
import { GiftCategory,
getTipCoffeeOption, GiftProduct,
type TipCoffeeType, } from "@/data/schemas/payment";
} from "@/lib/tip/tip_coffee";
export interface TipSuccessCopy { export const TIP_GIFT_PLACEHOLDER_IMAGE = "/images/tip/medium.png";
readonly title: string;
readonly body: readonly string[];
}
export function findTipCoffeePlan( export function formatGiftPrice(
plans: readonly PaymentPlan[], amountCents: number,
coffeeType: TipCoffeeType, currency: string,
): PaymentPlan | null {
const option = getTipCoffeeOption(coffeeType);
return plans.find((plan) => plan.planId === option.planId) ?? null;
}
export function formatTipPrice(
plan: PaymentPlan | null,
coffeeType: TipCoffeeType,
): string { ): string {
const option = getTipCoffeeOption(coffeeType); const normalizedCurrency = currency.trim().toUpperCase();
const amountCents = plan?.amountCents ?? option.amountCents; try {
const currency = plan?.currency.trim().toUpperCase() || "USD"; return new Intl.NumberFormat("en-US", {
style: "currency",
const amount = amountCents / 100; currency: normalizedCurrency,
const formattedAmount = Number.isInteger(amount) }).format(amountCents / 100);
? String(amount) } catch {
: amount.toFixed(2).replace(/\.?0+$/, ""); const amount = (amountCents / 100).toFixed(2);
return normalizedCurrency ? `${normalizedCurrency} ${amount}` : amount;
if (currency === "USD") return `US$ ${formattedAmount}`;
if (currency.length > 0) return `${currency} ${formattedAmount}`;
return formattedAmount;
}
export function formatEnglishOrdinal(value: number): string {
const remainder100 = value % 100;
if (remainder100 >= 11 && remainder100 <= 13) return `${value}th`;
switch (value % 10) {
case 1:
return `${value}st`;
case 2:
return `${value}nd`;
case 3:
return `${value}rd`;
default:
return `${value}th`;
} }
} }
export function resolveTipSuccessCopy( export function getGiftImageSources(
tipCount: number | null, product: GiftProduct | null,
thankYouMessage: string | null, category: GiftCategory | null,
): TipSuccessCopy { ): readonly string[] {
if (tipCount === 1) { return [
return { product?.imageUrl,
title: "Did you really just buy me a coffee?", category?.imageUrl,
body: [ TIP_GIFT_PLACEHOLDER_IMAGE,
"That honestly made me smile.", ].filter(
"Thank you. I'll definitely think of you while I enjoy it.", (source, index, sources): source is string =>
], Boolean(source) && sources.indexOf(source) === index,
}; );
}
if (tipCount !== null && tipCount > 1 && thankYouMessage) {
return {
title: `This is the ${formatEnglishOrdinal(tipCount)} coffee you've treated me to.`,
body: [thankYouMessage],
};
}
return {
title: "Thank you. Your coffee made me smile.",
body: [],
};
} }
+121
View File
@@ -59,6 +59,7 @@
.productCard, .productCard,
.paymentMethodSlot, .paymentMethodSlot,
.statusMessage, .statusMessage,
.catalogStatus,
.checkoutSlot { .checkoutSlot {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -225,6 +226,26 @@
letter-spacing: -0.04em; letter-spacing: -0.04em;
} }
.productDescription {
margin: 10px 0 0;
color: #7d6264;
font-size: clamp(12px, 3.148vw, 15px);
font-weight: 620;
line-height: 1.45;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.tierSelector { .tierSelector {
grid-column: 1 / -1; grid-column: 1 / -1;
min-width: 0; min-width: 0;
@@ -331,6 +352,17 @@
white-space: nowrap; white-space: nowrap;
} }
.tierDescription {
display: -webkit-box;
overflow: hidden;
color: #80676a;
font-size: 11px;
font-weight: 620;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.tierPriceBlock { .tierPriceBlock {
align-items: flex-end; align-items: flex-end;
gap: 3px; gap: 3px;
@@ -371,6 +403,82 @@
margin: 0; margin: 0;
} }
.catalogStatus {
display: grid;
justify-items: center;
gap: 12px;
margin-top: 20px;
padding: 22px;
border: 1px solid rgba(112, 71, 65, 0.1);
border-radius: 24px;
background: rgba(255, 255, 255, 0.78);
color: #76575c;
font-size: 14px;
font-weight: 700;
line-height: 1.5;
text-align: center;
}
.catalogStatus p {
margin: 0;
}
.catalogStatus button {
min-height: 40px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: #2b1a1e;
color: #ffffff;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 850;
}
.productSkeleton {
min-height: 260px;
}
.skeletonImage,
.skeletonCopy span {
background: linear-gradient(
100deg,
rgba(205, 172, 157, 0.14) 20%,
rgba(255, 255, 255, 0.72) 45%,
rgba(205, 172, 157, 0.14) 70%
);
background-size: 220% 100%;
animation: skeletonSweep 1.25s linear infinite;
}
.skeletonImage {
width: 100%;
aspect-ratio: 1;
border-radius: clamp(24px, 6.667vw, 32px);
}
.skeletonCopy {
display: grid;
gap: 12px;
}
.skeletonCopy span {
display: block;
height: 18px;
border-radius: 999px;
}
.skeletonCopy span:nth-child(2) {
width: 78%;
height: 38px;
}
.skeletonCopy span:nth-child(3) {
width: 56%;
height: 28px;
}
.statusMessage { .statusMessage {
margin-top: 14px; margin-top: 14px;
color: #b2474f; color: #b2474f;
@@ -467,3 +575,16 @@
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
} }
@keyframes skeletonSweep {
to {
background-position: -120% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeletonImage,
.skeletonCopy span {
animation: none;
}
}
+146 -122
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useEffect, type CSSProperties } from "react";
import Image from "next/image";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { BackButton, CharacterAvatar } from "@/app/_components"; import { BackButton, CharacterAvatar } from "@/app/_components";
@@ -16,13 +15,7 @@ import {
type PaymentAnalyticsContext, type PaymentAnalyticsContext,
} from "@/lib/analytics"; } from "@/lib/analytics";
import { getPaymentMethodConfig } from "@/lib/payment/payment_method"; import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
import { import { buildTipGiftPath } from "@/lib/tip/tip_gift";
buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE,
getTipCoffeeOption,
TIP_COFFEE_OPTIONS,
type TipCoffeeType,
} from "@/lib/tip/tip_coffee";
import { import {
useActiveCharacter, useActiveCharacter,
useActiveCharacterRoutes, useActiveCharacterRoutes,
@@ -30,13 +23,11 @@ import {
import { useUserState } from "@/stores/user/user-context"; import { useUserState } from "@/stores/user/user-context";
import { TipCheckoutButton } from "./tip-checkout-button"; import { TipCheckoutButton } from "./tip-checkout-button";
import { TipGiftProductSelector } from "./tip-gift-product-selector";
import { TipProductImage } from "./tip-product-image";
import { import {
TipCoffeeTierSelector, formatGiftPrice,
type TipCoffeeTierItem, getGiftImageSources,
} from "./tip-coffee-tier-selector";
import {
findTipCoffeePlan,
formatTipPrice,
} from "./tip-screen.helpers"; } from "./tip-screen.helpers";
import { TipSuccessView } from "./tip-success-view"; import { TipSuccessView } from "./tip-success-view";
import { useTipSupportPrompt } from "./use-tip-support-prompt"; import { useTipSupportPrompt } from "./use-tip-support-prompt";
@@ -48,13 +39,15 @@ const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
}; };
export interface TipScreenProps { export interface TipScreenProps {
coffeeType?: TipCoffeeType; initialCategory?: string | null;
initialPlanId?: string | null;
shouldResumePendingOrder?: boolean; shouldResumePendingOrder?: boolean;
initialPayChannel?: PayChannel | null; initialPayChannel?: PayChannel | null;
} }
export function TipScreen({ export function TipScreen({
coffeeType = DEFAULT_TIP_COFFEE_TYPE, initialCategory = null,
initialPlanId = null,
shouldResumePendingOrder = false, shouldResumePendingOrder = false,
initialPayChannel = null, initialPayChannel = null,
}: TipScreenProps) { }: TipScreenProps) {
@@ -66,63 +59,61 @@ export function TipScreen({
countryCode: userState.currentUser?.countryCode, countryCode: userState.currentUser?.countryCode,
requestedPayChannel: initialPayChannel, requestedPayChannel: initialPayChannel,
}); });
const [selectedCoffeeType, setSelectedCoffeeType] =
useState<TipCoffeeType>(coffeeType);
const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
const returnPath = buildTipCoffeePath(
selectedCoffeeType,
characterRoutes.tip,
);
const { payment, paymentDispatch } = usePaymentRouteFlow({ const { payment, paymentDispatch } = usePaymentRouteFlow({
catalog: "tip", catalog: "tip",
characterId: character.id,
initialCategory,
initialPlanId,
initialPayChannel: paymentMethodConfig.initialPayChannel, initialPayChannel: paymentMethodConfig.initialPayChannel,
paymentType: "tip", paymentType: "tip",
shouldResumePendingOrder, shouldResumePendingOrder,
}); });
const coffeeTiers = useMemo( const selectedCategory =
() => payment.giftCategories.find(
TIP_COFFEE_OPTIONS.map((option) => ({ (category) => category.category === payment.selectedGiftCategory,
option, ) ?? null;
plan: findTipCoffeePlan(payment.plans, option.type), const visibleProducts = payment.selectedGiftCategory
})), ? payment.giftProducts.filter(
[payment.plans], (product) => product.category === payment.selectedGiftCategory,
); )
const coffeePlan = : [];
coffeeTiers.find(({ option }) => option.type === selectedCoffeeType)?.plan ?? const selectedProduct =
visibleProducts.find(
(product) => product.planId === payment.selectedPlanId,
) ?? null;
const selectedPlan =
payment.plans.find((plan) => plan.planId === payment.selectedPlanId) ??
null; null;
const priceLabel = formatTipPrice(coffeePlan, selectedCoffeeType); const returnPath = buildTipGiftPath(
const availableCoffeePlans = useMemo( {
() => coffeeTiers.flatMap(({ plan }) => (plan ? [plan] : [])), category: payment.selectedGiftCategory,
[coffeeTiers], planId: payment.selectedPlanId || null,
},
characterRoutes.tip,
); );
const tierItems = useMemo<readonly TipCoffeeTierItem[]>(
() => usePaymentPlanAnalytics(payment.plans, TIP_ANALYTICS_CONTEXT);
coffeeTiers.map(({ option, plan }) => ({
type: option.type,
displayName: option.displayName,
priceLabel: formatTipPrice(plan, option.type),
unavailable:
payment.status === "ready" &&
!payment.isLoadingPlans &&
plan === null,
})),
[coffeeTiers, payment.isLoadingPlans, payment.status],
);
usePaymentPlanAnalytics(availableCoffeePlans, TIP_ANALYTICS_CONTEXT);
const isPaymentBusy = const isPaymentBusy =
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid; payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
const canCreateOrder = const canCreateOrder =
coffeePlan !== null && selectedProduct !== null &&
payment.selectedPlanId === coffeePlan.planId && selectedPlan !== null &&
payment.agreed && payment.agreed &&
!payment.autoRenew && !payment.autoRenew &&
!payment.isLoadingPlans && !payment.isLoadingPlans &&
!isPaymentBusy; !isPaymentBusy;
const showMissingPlan = const isSelectionDisabled =
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null; !["ready", "failed", "expired"].includes(payment.status) ||
const isTierSelectionDisabled = payment.isLoadingPlans ||
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; isPaymentBusy;
const catalogLoaded =
payment.status === "ready" && !payment.isLoadingPlans;
const showCatalogError =
catalogLoaded && payment.errorMessage !== null && visibleProducts.length === 0;
const showEmptyCatalog =
catalogLoaded && payment.errorMessage === null && visibleProducts.length === 0;
const handlePaymentMethodChange = (payChannel: PayChannel) => { const handlePaymentMethodChange = (payChannel: PayChannel) => {
paymentDispatch({ paymentDispatch({
@@ -140,18 +131,7 @@ export function TipScreen({
}); });
useEffect(() => { useEffect(() => {
if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) { if (!selectedProduct || isPaymentBusy || payment.isLoadingPlans) return;
return;
}
if (!coffeePlan) return;
if (payment.selectedPlanId !== coffeePlan.planId) {
paymentDispatch({
type: "PaymentPlanSelected",
planId: coffeePlan.planId,
});
return;
}
if (payment.autoRenew) { if (payment.autoRenew) {
paymentDispatch({ type: "PaymentAutoRenewChanged", autoRenew: false }); paymentDispatch({ type: "PaymentAutoRenewChanged", autoRenew: false });
@@ -162,55 +142,62 @@ export function TipScreen({
paymentDispatch({ type: "PaymentAgreementChanged", agreed: true }); paymentDispatch({ type: "PaymentAgreementChanged", agreed: true });
} }
}, [ }, [
coffeePlan, isPaymentBusy,
payment.agreed, payment.agreed,
payment.autoRenew, payment.autoRenew,
payment.isCreatingOrder,
payment.isLoadingPlans, payment.isLoadingPlans,
payment.isPollingOrder,
payment.selectedPlanId,
paymentDispatch, paymentDispatch,
selectedProduct,
]); ]);
const handleOrder = () => { const handleOrder = () => {
if (!canCreateOrder) return; if (!canCreateOrder || !selectedPlan) return;
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT); behaviorAnalytics.planClick(selectedPlan, TIP_ANALYTICS_CONTEXT);
paymentDispatch({ paymentDispatch({
type: "PaymentCreateOrderSubmitted", type: "PaymentCreateOrderSubmitted",
recipientCharacterId: character.id, recipientCharacterId: character.id,
}); });
}; };
const handleCoffeeTypeChange = (type: TipCoffeeType) => { const handleProductChange = (planId: string) => {
if (isTierSelectionDisabled) return; if (isSelectionDisabled || planId === payment.selectedPlanId) return;
const nextPlan = findTipCoffeePlan(payment.plans, type); if (!visibleProducts.some((product) => product.planId === planId)) return;
if (!nextPlan) return; paymentDispatch({ type: "PaymentPlanSelected", planId });
setSelectedCoffeeType(type);
if (payment.selectedPlanId !== nextPlan.planId) {
paymentDispatch({
type: "PaymentPlanSelected",
planId: nextPlan.planId,
});
}
};
const handleResetPaidState = () => {
paymentDispatch({ type: "PaymentReset" });
}; };
if (payment.isPaid) { if (payment.isPaid) {
const successProduct =
payment.giftProducts.find(
(product) => product.planId === payment.tipMessage?.planId,
) ?? selectedProduct;
const successCategory =
payment.giftCategories.find(
(category) => category.category === successProduct?.category,
) ?? selectedCategory;
return ( return (
<TipSuccessView <TipSuccessView
characterName={character.displayName} characterName={character.displayName}
characterAvatar={character.assets.avatar} characterAvatar={character.assets.avatar}
characterCover={character.assets.cover} characterCover={character.assets.cover}
coffeeOption={coffeeOption} giftImageSources={getGiftImageSources(
successProduct,
successCategory,
)}
giftName={
payment.tipMessage?.productName ??
successProduct?.planName ??
"Your gift"
}
isMessageLoading={payment.isLoadingTipMessage}
message={payment.tipMessage?.message ?? null}
messageError={payment.tipMessageError}
splashHref={characterRoutes.splash} splashHref={characterRoutes.splash}
tipCount={payment.tipCount} onRetryMessage={() =>
thankYouMessage={payment.thankYouMessage} paymentDispatch({ type: "PaymentTipMessageRetryRequested" })
onSendAgain={handleResetPaidState} }
onSendAgain={() => paymentDispatch({ type: "PaymentReset" })}
/> />
); );
} }
@@ -262,57 +249,94 @@ export function TipScreen({
</p> </p>
</section> </section>
<section className={styles.productCard} aria-label="Coffee tip product"> {payment.isLoadingPlans ? (
<section
className={`${styles.productCard} ${styles.productSkeleton}`}
aria-label="Loading gifts"
aria-busy="true"
>
<div className={styles.skeletonImage} />
<div className={styles.skeletonCopy}>
<span />
<span />
<span />
</div>
</section>
) : selectedProduct ? (
<section className={styles.productCard} aria-label="Gift products">
<div className={styles.coffeeStage}> <div className={styles.coffeeStage}>
<Image <TipProductImage
src={coffeeOption.image.src} key={selectedProduct.planId}
alt={`${coffeeOption.displayName} coffee`} sources={getGiftImageSources(selectedProduct, selectedCategory)}
width={coffeeOption.image.width} alt={selectedProduct.planName}
height={coffeeOption.image.height}
sizes="(max-width: 380px) 220px, 211px"
className={styles.coffeeImage} className={styles.coffeeImage}
priority
/> />
</div> </div>
<div className={styles.productCopy}> <div className={styles.productCopy}>
<span className={styles.productBadge}> <span className={styles.productBadge}>
<Sparkles size={14} aria-hidden="true" /> <Sparkles size={14} aria-hidden="true" />
Coffee Gift {selectedCategory?.name ?? "Gift"}
</span> </span>
<h2 className={styles.productName}> <h2 className={styles.productName}>{selectedProduct.planName}</h2>
{coffeeOption.displayName} <p className={styles.productPrice}>
</h2> {formatGiftPrice(
<p className={styles.productPrice}>{priceLabel}</p> selectedProduct.amountCents,
selectedProduct.currency,
)}
</p>
{selectedProduct.description ? (
<p className={styles.productDescription}>
{selectedProduct.description}
</p>
) : null}
</div> </div>
<TipCoffeeTierSelector <TipGiftProductSelector
disabled={isTierSelectionDisabled} disabled={isSelectionDisabled}
items={tierItems} products={visibleProducts}
onChange={handleCoffeeTypeChange} onChange={handleProductChange}
selectedType={selectedCoffeeType} selectedPlanId={payment.selectedPlanId}
/> />
</section> </section>
) : null}
{showCatalogError || showEmptyCatalog ? (
<section className={styles.catalogStatus} role="status">
<p>
{showCatalogError
? "We could not load gifts for this character."
: "This character does not have any gifts available yet."}
</p>
{showCatalogError ? (
<button
type="button"
onClick={() =>
paymentDispatch({ type: "PaymentCatalogRetryRequested" })
}
>
Try again
</button>
) : null}
</section>
) : null}
<PaymentMethodSelector <PaymentMethodSelector
config={paymentMethodConfig} config={paymentMethodConfig}
value={payment.payChannel} value={payment.payChannel}
disabled={isPaymentBusy} disabled={isPaymentBusy || !selectedProduct}
caption="GCash by default in the Philippines" caption="GCash by default in the Philippines"
className={styles.paymentMethodSlot} className={styles.paymentMethodSlot}
analyticsKey="tip.payment_method" analyticsKey="tip.payment_method"
onChange={handlePaymentMethodChange} onChange={handlePaymentMethodChange}
/> />
{showMissingPlan ? (
<p className={styles.statusMessage} role="alert">
Coffee tip is not available yet. Please try again later.
</p>
) : null}
<div className={styles.checkoutSlot}> <div className={styles.checkoutSlot}>
<TipCheckoutButton <TipCheckoutButton
coffeeType={selectedCoffeeType} giftCategory={payment.selectedGiftCategory}
disabled={showMissingPlan || !canCreateOrder} giftPlanId={payment.selectedPlanId || null}
disabled={!canCreateOrder}
onOrder={handleOrder} onOrder={handleOrder}
returnPath={returnPath} returnPath={returnPath}
/> />
+32
View File
@@ -174,6 +174,38 @@
margin: 0; margin: 0;
} }
.messageRetry {
display: grid;
justify-items: center;
gap: 9px;
margin-top: 15px;
color: #8a6870;
font-size: 12px;
font-weight: 650;
}
.messageRetry p {
margin: 0;
}
.retryAction {
min-height: 36px;
padding: 0 15px;
border: 1px solid rgba(199, 68, 112, 0.18);
border-radius: 999px;
background: #fff0f4;
color: #bd3d69;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 850;
}
.retryAction:disabled {
cursor: wait;
opacity: 0.62;
}
.actions { .actions {
display: grid; display: grid;
gap: 10px; gap: 10px;
+44 -25
View File
@@ -1,25 +1,29 @@
"use client"; "use client";
import { useEffect, useRef, type CSSProperties } from "react"; import { useEffect, useRef, type CSSProperties } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Coffee, Heart, Sparkles } from "lucide-react"; import { Gift, Heart, Sparkles } from "lucide-react";
import { CharacterAvatar } from "@/app/_components"; import { CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import type { TipCoffeeOption } from "@/lib/tip/tip_coffee";
import { resolveTipSuccessCopy } from "./tip-screen.helpers"; import { TipProductImage } from "./tip-product-image";
import styles from "./tip-success-view.module.css"; import styles from "./tip-success-view.module.css";
const GENERIC_TIP_SUCCESS_MESSAGE =
"Thank you. Your gift made me smile.";
export interface TipSuccessViewProps { export interface TipSuccessViewProps {
characterName: string; characterName: string;
characterAvatar: string; characterAvatar: string;
characterCover: string; characterCover: string;
coffeeOption: TipCoffeeOption; giftImageSources: readonly string[];
giftName: string;
isMessageLoading: boolean;
message: string | null;
messageError: string | null;
splashHref: string; splashHref: string;
tipCount: number | null; onRetryMessage: () => void;
thankYouMessage: string | null;
onSendAgain: () => void; onSendAgain: () => void;
} }
@@ -27,14 +31,16 @@ export function TipSuccessView({
characterName, characterName,
characterAvatar, characterAvatar,
characterCover, characterCover,
coffeeOption, giftImageSources,
giftName,
isMessageLoading,
message,
messageError,
splashHref, splashHref,
tipCount, onRetryMessage,
thankYouMessage,
onSendAgain, onSendAgain,
}: TipSuccessViewProps) { }: TipSuccessViewProps) {
const titleRef = useRef<HTMLHeadingElement>(null); const titleRef = useRef<HTMLHeadingElement>(null);
const copy = resolveTipSuccessCopy(tipCount, thankYouMessage);
useEffect(() => { useEffect(() => {
titleRef.current?.focus({ preventScroll: true }); titleRef.current?.focus({ preventScroll: true });
@@ -76,12 +82,10 @@ export function TipSuccessView({
/> />
</div> </div>
<div className={styles.coffeeFrame}> <div className={styles.coffeeFrame}>
<Image <TipProductImage
src={coffeeOption.image.src} key={giftName}
alt={`${coffeeOption.displayName} coffee`} sources={giftImageSources}
width={coffeeOption.image.width} alt={giftName}
height={coffeeOption.image.height}
sizes="78px"
className={styles.coffeeImage} className={styles.coffeeImage}
priority priority
/> />
@@ -89,8 +93,8 @@ export function TipSuccessView({
</div> </div>
<p className={styles.eyebrow}> <p className={styles.eyebrow}>
<Coffee size={15} aria-hidden="true" /> <Gift size={15} aria-hidden="true" />
Coffee received Gift received
</p> </p>
<h1 <h1
ref={titleRef} ref={titleRef}
@@ -98,13 +102,28 @@ export function TipSuccessView({
className={styles.title} className={styles.title}
tabIndex={-1} tabIndex={-1}
> >
{copy.title} You made {characterName} smile.
</h1> </h1>
{copy.body.length > 0 ? (
<div className={styles.copy}> <div className={styles.copy}>
{copy.body.map((paragraph) => ( <p>
<p key={paragraph}>{paragraph}</p> {message ??
))} (isMessageLoading
? "Your gift arrived. A thank-you note is on its way."
: GENERIC_TIP_SUCCESS_MESSAGE)}
</p>
</div>
{messageError ? (
<div className={styles.messageRetry}>
<p>We could not load the personal thank-you note.</p>
<button
type="button"
className={styles.retryAction}
disabled={isMessageLoading}
onClick={onRetryMessage}
>
{isMessageLoading ? "Retrying..." : "Retry message"}
</button>
</div> </div>
) : null} ) : null}
@@ -115,7 +134,7 @@ export function TipSuccessView({
data-analytics-key="tip.send_again" data-analytics-key="tip.send_again"
onClick={onSendAgain} onClick={onSendAgain}
> >
Send another coffee Send another gift
</button> </button>
<Link <Link
href={splashHref} href={splashHref}
@@ -1,38 +1,73 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { PaymentRepository } from "@/data/repositories/payment_repository"; import { PaymentRepository } from "@/data/repositories/payment_repository";
import { TipPaymentPlansResponseSchema } from "@/data/schemas/payment"; import {
GiftProductsResponseSchema,
TipMessageResponseSchema,
} from "@/data/schemas/payment";
import type { PaymentApi } from "@/data/services/api"; import type { PaymentApi } from "@/data/services/api";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
describe("PaymentRepository", () => { describe("PaymentRepository", () => {
it("adapts tip plans without caching them as subscription plans", async () => { it("loads a character gift catalog without adapting product metadata", async () => {
const getTipPlans = vi.fn().mockResolvedValue( const catalog = GiftProductsResponseSchema.parse({
TipPaymentPlansResponseSchema.parse({ characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [ plans: [
{ {
planId: "tip_coffee_usd_9_99", planId: "tip_coffee_usd_9_99",
planName: "Medium Coffee", planName: "Golden Reserve",
orderType: "tip",
tipType: "coffee_medium",
category: "coffee",
characterId: "elio",
description: "Buy Elio a coffee",
imageUrl: null,
amountCents: 999, amountCents: 999,
currency: "USD", currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
}, },
], ],
}), });
); const getGiftProducts = vi.fn().mockResolvedValue(catalog);
const repository = new PaymentRepository({ const repository = new PaymentRepository({
getTipPlans, getGiftProducts,
} as unknown as PaymentApi); } as unknown as PaymentApi);
const result = await repository.getTipPlans(); const result = await repository.getGiftProducts("elio");
expect(getTipPlans).toHaveBeenCalledOnce(); expect(getGiftProducts).toHaveBeenCalledWith("elio");
expect(Result.isOk(result) && result.data.plans[0]).toMatchObject({ expect(Result.isOk(result) && result.data).toBe(catalog);
planId: "tip_coffee_usd_9_99",
planName: "Medium Coffee",
orderType: "tip",
amountCents: 999,
currency: "USD",
isFirstRechargeOffer: false,
}); });
it("validates and forwards a Tip message order id", async () => {
const tipMessage = TipMessageResponseSchema.parse({
orderId: "pay_xxx",
characterId: "elio",
planId: "tip_coffee_usd_9_99",
productName: "Golden Reserve",
tipCount: 1,
poolIndex: 1,
message: "Thank you.",
});
const getTipMessage = vi.fn().mockResolvedValue(tipMessage);
const repository = new PaymentRepository({
getTipMessage,
} as unknown as PaymentApi);
const result = await repository.getTipMessage("pay_xxx");
expect(getTipMessage).toHaveBeenCalledWith({ orderId: "pay_xxx" });
expect(Result.isOk(result) && result.data.message).toBe("Thank you.");
}); });
}); });
@@ -3,9 +3,11 @@
*/ */
import type { import type {
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
GiftProductsResponse,
PayChannel, PayChannel,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentPlansResponse, PaymentPlansResponse,
TipMessageResponse,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import type { Result } from "@/utils/result"; import type { Result } from "@/utils/result";
@@ -16,8 +18,8 @@ export interface IPaymentRepository {
/** 获取本地缓存套餐列表。 */ /** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>; getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
/** 获取咖啡打赏套餐列表。 */ /** 获取当前角色的完整礼物目录。 */
getTipPlans(): Promise<Result<PaymentPlansResponse>>; getGiftProducts(characterId: string): Promise<Result<GiftProductsResponse>>;
/** 清除本地缓存套餐列表。 */ /** 清除本地缓存套餐列表。 */
clearCachedPlans(): Promise<Result<void>>; clearCachedPlans(): Promise<Result<void>>;
@@ -32,4 +34,7 @@ export interface IPaymentRepository {
/** 查询支付订单状态。 */ /** 查询支付订单状态。 */
getOrderStatus(orderId: string): Promise<Result<PaymentOrderStatusResponse>>; getOrderStatus(orderId: string): Promise<Result<PaymentOrderStatusResponse>>;
/** 获取已支付礼物订单的角色感谢文案。 */
getTipMessage(orderId: string): Promise<Result<TipMessageResponse>>;
} }
+14 -19
View File
@@ -7,10 +7,13 @@ import type { IPaymentRepository } from "@/data/repositories/interfaces";
import { import {
CreatePaymentOrderRequestSchema, CreatePaymentOrderRequestSchema,
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
GiftProductsResponse,
PayChannel, PayChannel,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentPlansResponse, PaymentPlansResponse,
PaymentPlansResponseSchema, PaymentPlansResponseSchema,
TipMessageRequestSchema,
TipMessageResponse,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import { PaymentApi, paymentApi } from "@/data/services/api"; import { PaymentApi, paymentApi } from "@/data/services/api";
import { PaymentPlansStorage } from "@/data/storage/payment"; import { PaymentPlansStorage } from "@/data/storage/payment";
@@ -38,25 +41,11 @@ export class PaymentRepository implements IPaymentRepository {
}); });
} }
/** 获取咖啡打赏套餐列表,不写入通用套餐缓存。 */ /** 获取当前角色的完整礼物目录,不写入订阅套餐缓存。 */
async getTipPlans(): Promise<Result<PaymentPlansResponse>> { async getGiftProducts(
return Result.wrap(async () => { characterId: string,
const response = await this.api.getTipPlans(); ): Promise<Result<GiftProductsResponse>> {
return PaymentPlansResponseSchema.parse({ return Result.wrap(() => this.api.getGiftProducts(characterId));
plans: response.plans.map((plan) => ({
...plan,
orderType: "tip",
vipDays: null,
dolAmount: null,
creditBalance: 0,
originalAmountCents: null,
dailyPriceCents: null,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null,
promotionType: null,
})),
});
});
} }
/** 清除本地缓存套餐列表。 */ /** 清除本地缓存套餐列表。 */
@@ -89,6 +78,12 @@ export class PaymentRepository implements IPaymentRepository {
): Promise<Result<PaymentOrderStatusResponse>> { ): Promise<Result<PaymentOrderStatusResponse>> {
return Result.wrap(() => this.api.getOrderStatus(orderId)); return Result.wrap(() => this.api.getOrderStatus(orderId));
} }
/** 获取已支付礼物订单的角色感谢文案。 */
async getTipMessage(orderId: string): Promise<Result<TipMessageResponse>> {
const request = TipMessageRequestSchema.parse({ orderId });
return Result.wrap(() => this.api.getTipMessage(request));
}
} }
/** 全局懒单例。 */ /** 全局懒单例。 */
@@ -2,21 +2,21 @@ import { describe, expect, it } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { import {
GiftProductsResponseSchema,
PaymentOrderStatusResponseSchema, PaymentOrderStatusResponseSchema,
PaymentPlanSchema, PaymentPlanSchema,
PaymentPlansResponseSchema, PaymentPlansResponseSchema,
TipPaymentPlansResponseSchema, TipMessageResponseSchema,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
describe("PaymentOrderStatusResponse", () => { describe("PaymentOrderStatusResponse", () => {
it("parses paid Tip success metadata", () => { it("parses the paid gift order shape", () => {
const response = PaymentOrderStatusResponseSchema.parse({ const response = PaymentOrderStatusResponseSchema.parse({
orderId: "tip_order_123", orderId: "tip_order_123",
status: "paid", status: "paid",
orderType: "tip", orderType: "tip",
planId: "tip_coffee_usd_9_99", planId: "tip_coffee_usd_9_99",
tipCount: 2, creditsAdded: 0,
thankYouMessage: " You made my day. ",
}); });
expect(response).toEqual({ expect(response).toEqual({
@@ -24,37 +24,20 @@ describe("PaymentOrderStatusResponse", () => {
status: "paid", status: "paid",
orderType: "tip", orderType: "tip",
planId: "tip_coffee_usd_9_99", planId: "tip_coffee_usd_9_99",
tipCount: 2, creditsAdded: 0,
thankYouMessage: "You made my day.",
}); });
}); });
it("degrades missing or invalid optional Tip metadata to null", () => { it("accepts expired status and a nullable plan id", () => {
expect( expect(
PaymentOrderStatusResponseSchema.parse({ PaymentOrderStatusResponseSchema.parse({
orderId: "pay_order_456", orderId: "pay_order_456",
status: "pending", status: "expired",
orderType: "vip_monthly",
planId: "vip_monthly",
}),
).toMatchObject({
tipCount: null,
thankYouMessage: null,
});
expect(
PaymentOrderStatusResponseSchema.parse({
orderId: "tip_order_invalid",
status: "paid",
orderType: "tip", orderType: "tip",
planId: "tip_coffee_usd_4_99", planId: null,
tipCount: 0, creditsAdded: 0,
thankYouMessage: " ",
}), }),
).toMatchObject({ ).toMatchObject({ status: "expired", planId: null, creditsAdded: 0 });
tipCount: null,
thankYouMessage: null,
});
}); });
}); });
@@ -209,34 +192,58 @@ describe("PaymentPlansResponse", () => {
}); });
}); });
describe("TipPaymentPlansResponse", () => { describe("GiftProductsResponse", () => {
it("keeps only fields used by the tip payment flow", () => { it("parses and freezes categories and complete gift products", () => {
const response = TipPaymentPlansResponseSchema.parse({ const response = GiftProductsResponseSchema.parse({
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [ plans: [
{ {
planId: "tip_coffee_usd_4_99", planId: "tip_coffee_usd_4_99",
planName: "Small Coffee", planName: "Velvet Espresso",
orderType: "tip", orderType: "tip",
tipType: "coffee_small", tipType: "coffee_small",
category: "coffee",
characterId: "elio",
description: "Buy Elio a small coffee", description: "Buy Elio a small coffee",
imageUrl: null,
amountCents: 499, amountCents: 499,
currency: "USD", currency: "USD",
autoRenew: false, autoRenew: false,
isFirstRechargeOffer: false, isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0, firstRechargeDiscountPercent: 0,
promotionType: null,
}, },
], ],
}); });
expect(response).toEqual({ expect(response.categories[0]?.category).toBe("coffee");
plans: [ expect(response.plans[0]?.description).toBe("Buy Elio a small coffee");
{ expect(Object.isFrozen(response)).toBe(true);
planId: "tip_coffee_usd_4_99", expect(Object.isFrozen(response.categories)).toBe(true);
planName: "Small Coffee", expect(Object.isFrozen(response.plans[0])).toBe(true);
amountCents: 499, });
currency: "USD", });
},
], describe("TipMessageResponse", () => {
}); it("parses the complete stable backend message", () => {
const response = TipMessageResponseSchema.parse({
orderId: "pay_xxx",
characterId: "elio",
planId: "tip_coffee_usd_4_99",
productName: "Velvet Espresso",
tipCount: 2,
poolIndex: 37,
message: "You have a knack for making me smile.",
});
expect(response.message).toBe("You have a knack for making me smile.");
}); });
}); });
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod";
export const GiftCategorySchema = z
.object({
category: z.string().min(1),
name: z.string(),
productCount: z.number().int().nonnegative(),
imageUrl: z.string().nullable(),
})
.readonly();
export type GiftCategoryInput = z.input<typeof GiftCategorySchema>;
export type GiftCategoryData = z.output<typeof GiftCategorySchema>;
export type GiftCategory = GiftCategoryData;
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
import { stringOrNull } from "../nullable-defaults";
export const GiftProductSchema = z
.object({
planId: z.string().min(1),
planName: z.string(),
orderType: z.literal("tip"),
tipType: z.string(),
category: z.string().min(1),
characterId: z.string().min(1),
description: z.string(),
imageUrl: z.string().nullable(),
amountCents: z.number().int().nonnegative(),
currency: z.string(),
autoRenew: z.literal(false),
isFirstRechargeOffer: z.literal(false),
firstRechargeDiscountPercent: z.number().int(),
promotionType: stringOrNull,
})
.readonly();
export type GiftProductInput = z.input<typeof GiftProductSchema>;
export type GiftProductData = z.output<typeof GiftProductSchema>;
export type GiftProduct = GiftProductData;
+5 -2
View File
@@ -3,9 +3,12 @@
*/ */
export * from "./payment_plan"; export * from "./payment_plan";
export * from "./tip_payment_plan"; export * from "./gift_category";
export * from "./gift_product";
export * from "./request/create_payment_order_request"; export * from "./request/create_payment_order_request";
export * from "./request/tip_message_request";
export * from "./response/create_payment_order_response"; export * from "./response/create_payment_order_response";
export * from "./response/gift_products_response";
export * from "./response/payment_order_status_response"; export * from "./response/payment_order_status_response";
export * from "./response/payment_plans_response"; export * from "./response/payment_plans_response";
export * from "./response/tip_payment_plans_response"; export * from "./response/tip_message_response";
@@ -3,3 +3,4 @@
*/ */
export * from "./create_payment_order_request"; export * from "./create_payment_order_request";
export * from "./tip_message_request";
@@ -0,0 +1,11 @@
import { z } from "zod";
export const TipMessageRequestSchema = z
.object({
orderId: z.string().min(1),
})
.readonly();
export type TipMessageRequestInput = z.input<typeof TipMessageRequestSchema>;
export type TipMessageRequestData = z.output<typeof TipMessageRequestSchema>;
export type TipMessageRequest = TipMessageRequestData;
@@ -0,0 +1,21 @@
import { z } from "zod";
import { arrayOrEmpty, stringOrNull } from "../../nullable-defaults";
import { GiftCategorySchema } from "../gift_category";
import { GiftProductSchema } from "../gift_product";
export const GiftProductsResponseSchema = z
.object({
characterId: stringOrNull,
categories: arrayOrEmpty(GiftCategorySchema),
plans: arrayOrEmpty(GiftProductSchema),
})
.readonly();
export type GiftProductsResponseInput = z.input<
typeof GiftProductsResponseSchema
>;
export type GiftProductsResponseData = z.output<
typeof GiftProductsResponseSchema
>;
export type GiftProductsResponse = GiftProductsResponseData;
+2 -1
View File
@@ -3,6 +3,7 @@
*/ */
export * from "./create_payment_order_response"; export * from "./create_payment_order_response";
export * from "./gift_products_response";
export * from "./payment_order_status_response"; export * from "./payment_order_status_response";
export * from "./payment_plans_response"; export * from "./payment_plans_response";
export * from "./tip_payment_plans_response"; export * from "./tip_message_response";
@@ -3,33 +3,20 @@
*/ */
import { z } from "zod"; import { z } from "zod";
export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]); export const PaymentOrderStatusSchema = z.enum([
"pending",
const tipCountOrNull = z.preprocess( "paid",
(value) => "failed",
typeof value === "number" && Number.isInteger(value) && value > 0 "expired",
? value ]);
: null,
z.number().int().positive().nullable(),
);
const thankYouMessageOrNull = z.preprocess(
(value) => {
if (typeof value !== "string") return null;
const message = value.trim();
return message.length > 0 ? message : null;
},
z.string().nullable(),
);
export const PaymentOrderStatusResponseSchema = z export const PaymentOrderStatusResponseSchema = z
.object({ .object({
orderId: z.string(), orderId: z.string(),
status: PaymentOrderStatusSchema, status: PaymentOrderStatusSchema,
orderType: z.string(), orderType: z.string(),
planId: z.string(), planId: z.string().nullable(),
tipCount: tipCountOrNull, creditsAdded: z.number().int(),
thankYouMessage: thankYouMessageOrNull,
}) })
.readonly(); .readonly();
@@ -0,0 +1,17 @@
import { z } from "zod";
export const TipMessageResponseSchema = z
.object({
orderId: z.string().min(1),
characterId: z.string().min(1),
planId: z.string().min(1),
productName: z.string(),
tipCount: z.number().int().positive(),
poolIndex: z.number().int().min(0).max(99),
message: z.string().min(1),
})
.readonly();
export type TipMessageResponseInput = z.input<typeof TipMessageResponseSchema>;
export type TipMessageResponseData = z.output<typeof TipMessageResponseSchema>;
export type TipMessageResponse = TipMessageResponseData;
@@ -1,19 +0,0 @@
import { z } from "zod";
import { arrayOrEmpty } from "../../nullable-defaults";
import { TipPaymentPlanSchema } from "../tip_payment_plan";
export const TipPaymentPlansResponseSchema = z
.object({
plans: arrayOrEmpty(TipPaymentPlanSchema),
})
.readonly();
export type TipPaymentPlansResponseInput = z.input<
typeof TipPaymentPlansResponseSchema
>;
export type TipPaymentPlansResponseData = z.output<
typeof TipPaymentPlansResponseSchema
>;
export type TipPaymentPlansResponse = TipPaymentPlansResponseData;
@@ -1,15 +0,0 @@
import { z } from "zod";
export const TipPaymentPlanSchema = z
.object({
planId: z.string(),
planName: z.string(),
amountCents: z.number(),
currency: z.string(),
})
.readonly();
export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>;
export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>;
export type TipPaymentPlan = TipPaymentPlanData;
@@ -13,39 +13,72 @@ describe("PaymentApi", () => {
httpClientMock.mockReset(); httpClientMock.mockReset();
}); });
it("loads public coffee tip plans from the dedicated endpoint", async () => { it("loads the complete public gift catalog for a character", async () => {
httpClientMock.mockResolvedValue({ httpClientMock.mockResolvedValue({
success: true, success: true,
data: { data: {
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [ plans: [
{ {
planId: "tip_coffee_usd_19_99", planId: "tip_coffee_usd_19_99",
planName: "Large Coffee", planName: "Large Coffee",
orderType: "tip", orderType: "tip",
tipType: "coffee_large", tipType: "coffee_large",
category: "coffee",
characterId: "elio",
description: "Buy Elio a large coffee", description: "Buy Elio a large coffee",
imageUrl: null,
amountCents: 1999, amountCents: 1999,
currency: "USD", currency: "USD",
autoRenew: false, autoRenew: false,
isFirstRechargeOffer: false, isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0, firstRechargeDiscountPercent: 0,
promotionType: null,
}, },
], ],
}, },
}); });
const response = await new PaymentApi().getTipPlans(); const response = await new PaymentApi().getGiftProducts("elio");
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans"); expect(httpClientMock).toHaveBeenCalledWith(
expect(response).toEqual({ "/api/payment/gift-products",
plans: [ { query: { characterId: "elio" } },
{ );
expect(response.categories[0]?.category).toBe("coffee");
expect(response.plans[0]?.planId).toBe("tip_coffee_usd_19_99");
});
it("posts the paid order id when loading the Tip message", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
orderId: "pay_xxx",
characterId: "elio",
planId: "tip_coffee_usd_19_99", planId: "tip_coffee_usd_19_99",
planName: "Large Coffee", productName: "Large Coffee",
amountCents: 1999, tipCount: 1,
currency: "USD", poolIndex: 7,
message: "Thank you for the thoughtful gift.",
}, },
],
}); });
const response = await new PaymentApi().getTipMessage({
orderId: "pay_xxx",
});
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-message", {
method: "POST",
body: { orderId: "pay_xxx" },
});
expect(response.message).toBe("Thank you for the thoughtful gift.");
}); });
}); });
+2 -1
View File
@@ -15,7 +15,8 @@
"paymentCreateOrder": { "method": "post", "path": "/api/payment/create-order" }, "paymentCreateOrder": { "method": "post", "path": "/api/payment/create-order" },
"paymentOrderStatus": { "method": "get", "path": "/api/payment/order-status" }, "paymentOrderStatus": { "method": "get", "path": "/api/payment/order-status" },
"paymentPlans": { "method": "get", "path": "/api/payment/plans" }, "paymentPlans": { "method": "get", "path": "/api/payment/plans" },
"paymentTipPlans": { "method": "get", "path": "/api/payment/tip-plans" }, "paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" },
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
"chatSend": { "method": "post", "path": "/api/chat/send" }, "chatSend": { "method": "post", "path": "/api/chat/send" },
"chatHistory": { "method": "get", "path": "/api/chat/history" }, "chatHistory": { "method": "get", "path": "/api/chat/history" },
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" }, "chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
+5 -2
View File
@@ -60,8 +60,11 @@ export class ApiPath {
/** 获取商品套餐列表 */ /** 获取商品套餐列表 */
static readonly paymentPlans = apiContract.paymentPlans.path; static readonly paymentPlans = apiContract.paymentPlans.path;
/** 获取咖啡打赏套餐列表 */ /** 获取角色的完整礼物目录 */
static readonly paymentTipPlans = apiContract.paymentTipPlans.path; static readonly paymentGiftProducts = apiContract.paymentGiftProducts.path;
/** 获取已支付礼物订单的角色感谢文案 */
static readonly paymentTipMessage = apiContract.paymentTipMessage.path;
// ============ 聊天相关 ============ // ============ 聊天相关 ============
/** 发送消息 */ /** 发送消息 */
+26 -6
View File
@@ -7,12 +7,15 @@ import {
CreatePaymentOrderRequest, CreatePaymentOrderRequest,
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
CreatePaymentOrderResponseSchema, CreatePaymentOrderResponseSchema,
GiftProductsResponse,
GiftProductsResponseSchema,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentOrderStatusResponseSchema, PaymentOrderStatusResponseSchema,
PaymentPlansResponse, PaymentPlansResponse,
PaymentPlansResponseSchema, PaymentPlansResponseSchema,
TipPaymentPlansResponse, TipMessageRequest,
TipPaymentPlansResponseSchema, TipMessageResponse,
TipMessageResponseSchema,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import { ApiPath } from "./api_path"; import { ApiPath } from "./api_path";
@@ -28,10 +31,13 @@ export class PaymentApi {
); );
} }
/** 获取咖啡打赏套餐列表。 */ /** 一次获取当前角色的完整礼物目录。 */
async getTipPlans(): Promise<TipPaymentPlansResponse> { async getGiftProducts(characterId: string): Promise<GiftProductsResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentTipPlans); const env = await httpClient<ApiEnvelope<unknown>>(
return TipPaymentPlansResponseSchema.parse( ApiPath.paymentGiftProducts,
{ query: { characterId } },
);
return GiftProductsResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
@@ -64,6 +70,20 @@ export class PaymentApi {
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
/** 获取已支付礼物订单的稳定感谢文案。 */
async getTipMessage(body: TipMessageRequest): Promise<TipMessageResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.paymentTipMessage,
{
method: "POST",
body,
},
);
return TipMessageResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
} }
/** /**
@@ -7,15 +7,18 @@ import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys"; import { StorageKeys } from "../storage_keys";
const PendingPaymentOrderSchema = z.object({ const PendingPaymentOrderSchema = z
.object({
orderId: z.string().min(1), orderId: z.string().min(1),
payChannel: z.literal("ezpay"), payChannel: z.literal("ezpay"),
subscriptionType: z.enum(["vip", "topup", "tip"]), subscriptionType: z.enum(["vip", "topup", "tip"]),
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(), giftCategory: z.string().min(1).nullable().default(null),
giftPlanId: z.string().min(1).nullable().default(null),
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(), returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(), characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
createdAt: z.number(), createdAt: z.number(),
}); })
.readonly();
export type PendingPaymentOrder = z.output<typeof PendingPaymentOrderSchema>; export type PendingPaymentOrder = z.output<typeof PendingPaymentOrderSchema>;
@@ -88,26 +88,27 @@ describe("pending payment order helpers", () => {
await clearPendingPaymentOrder(); await clearPendingPaymentOrder();
}); });
it("routes tip payments back to the tip page", () => { it("routes tip payments back with dynamic category and plan id", () => {
expect( expect(
buildPendingPaymentSubscriptionUrl({ buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay", payChannel: "ezpay",
subscriptionType: "tip", subscriptionType: "tip",
tipCoffeeType: "large", giftCategory: "coffee",
giftPlanId: "tip_coffee_usd_19_99",
}), }),
).toBe( ).toBe(
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large", "/characters/elio/tip?category=coffee&planId=tip_coffee_usd_19_99&payChannel=ezpay&paymentReturn=1",
); );
}); });
it("defaults legacy tip payment returns to medium coffee", () => { it("lets legacy tip records fall back to the first backend product", () => {
expect( expect(
buildPendingPaymentSubscriptionUrl({ buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay", payChannel: "ezpay",
subscriptionType: "tip", subscriptionType: "tip",
}), }),
).toBe( ).toBe(
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium", "/characters/elio/tip?payChannel=ezpay&paymentReturn=1",
); );
}); });
@@ -116,11 +117,12 @@ describe("pending payment order helpers", () => {
buildPendingPaymentSubscriptionUrl({ buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay", payChannel: "ezpay",
subscriptionType: "tip", subscriptionType: "tip",
tipCoffeeType: "small", giftCategory: "coffee",
giftPlanId: "tip_coffee_usd_4_99",
characterSlug: "nayeli", characterSlug: "nayeli",
}), }),
).toBe( ).toBe(
"/characters/nayeli/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small", "/characters/nayeli/tip?category=coffee&planId=tip_coffee_usd_4_99&payChannel=ezpay&paymentReturn=1",
); );
}); });
}); });
+6 -4
View File
@@ -7,7 +7,6 @@ import {
savePendingEzpayOrder, savePendingEzpayOrder,
type PendingPaymentReturnTo, type PendingPaymentReturnTo,
type PendingPaymentSubscriptionType, type PendingPaymentSubscriptionType,
type PendingPaymentTipCoffeeType,
} from "./pending_payment_order"; } from "./pending_payment_order";
const log = new Logger("LibPaymentPaymentLaunch"); const log = new Logger("LibPaymentPaymentLaunch");
@@ -63,7 +62,8 @@ export interface LaunchEzpayRedirectInput {
orderId: string | null; orderId: string | null;
paymentUrl: string; paymentUrl: string;
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType; giftCategory?: string | null;
giftPlanId?: string | null;
returnTo?: PendingPaymentReturnTo; returnTo?: PendingPaymentReturnTo;
characterSlug?: string; characterSlug?: string;
onOpened?: () => void; onOpened?: () => void;
@@ -74,7 +74,8 @@ export async function launchEzpayRedirect({
orderId, orderId,
paymentUrl, paymentUrl,
subscriptionType, subscriptionType,
tipCoffeeType, giftCategory,
giftPlanId,
returnTo, returnTo,
characterSlug, characterSlug,
onOpened, onOpened,
@@ -101,7 +102,8 @@ export async function launchEzpayRedirect({
const saveResult = await savePendingEzpayOrder({ const saveResult = await savePendingEzpayOrder({
orderId, orderId,
subscriptionType, subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}), giftCategory,
giftPlanId,
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
...(characterSlug ? { characterSlug } : {}), ...(characterSlug ? { characterSlug } : {}),
}); });
+16 -18
View File
@@ -9,23 +9,18 @@ import {
getCharacterBySlug, getCharacterBySlug,
} from "@/data/constants/character"; } from "@/data/constants/character";
import { getCharacterRoutes, ROUTES } from "@/router/routes"; import { getCharacterRoutes, ROUTES } from "@/router/routes";
import { import { buildTipGiftPath } from "@/lib/tip/tip_gift";
DEFAULT_TIP_COFFEE_TYPE,
TIP_COFFEE_TYPE_PARAM,
} from "@/lib/tip/tip_coffee";
import type { Result } from "@/utils/result"; import type { Result } from "@/utils/result";
export type PendingPaymentSubscriptionType = export type PendingPaymentSubscriptionType =
PendingPaymentOrder["subscriptionType"]; PendingPaymentOrder["subscriptionType"];
export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"]; export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"];
export type PendingPaymentTipCoffeeType = NonNullable<
PendingPaymentOrder["tipCoffeeType"]
>;
export function savePendingEzpayOrder(input: { export function savePendingEzpayOrder(input: {
orderId: string; orderId: string;
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType; giftCategory?: string | null;
giftPlanId?: string | null;
returnTo?: PendingPaymentReturnTo; returnTo?: PendingPaymentReturnTo;
characterSlug?: string; characterSlug?: string;
createdAt?: number; createdAt?: number;
@@ -34,7 +29,8 @@ export function savePendingEzpayOrder(input: {
orderId: input.orderId, orderId: input.orderId,
payChannel: "ezpay", payChannel: "ezpay",
subscriptionType: input.subscriptionType, subscriptionType: input.subscriptionType,
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}), giftCategory: input.giftCategory ?? null,
giftPlanId: input.giftPlanId ?? null,
...(input.returnTo ? { returnTo: input.returnTo } : {}), ...(input.returnTo ? { returnTo: input.returnTo } : {}),
...(input.characterSlug ? { characterSlug: input.characterSlug } : {}), ...(input.characterSlug ? { characterSlug: input.characterSlug } : {}),
createdAt: input.createdAt ?? Date.now(), createdAt: input.createdAt ?? Date.now(),
@@ -63,22 +59,24 @@ export function buildPendingPaymentSubscriptionUrl(
| "payChannel" | "payChannel"
| "returnTo" | "returnTo"
| "subscriptionType" | "subscriptionType"
| "tipCoffeeType"
| "characterSlug" | "characterSlug"
>, > &
Partial<Pick<PendingPaymentOrder, "giftCategory" | "giftPlanId">>,
): string { ): string {
const characterSlug = const characterSlug =
getCharacterBySlug(order.characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG; getCharacterBySlug(order.characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
const characterRoutes = getCharacterRoutes(characterSlug); const characterRoutes = getCharacterRoutes(characterSlug);
if (order.subscriptionType === "tip") { if (order.subscriptionType === "tip") {
const params = new URLSearchParams({ const path = buildTipGiftPath(
payChannel: order.payChannel, {
paymentReturn: "1", category: order.giftCategory ?? null,
[TIP_COFFEE_TYPE_PARAM]: planId: order.giftPlanId ?? null,
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE, },
}); characterRoutes.tip,
return `${characterRoutes.tip}?${params.toString()}`; );
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}payChannel=${order.payChannel}&paymentReturn=1`;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE,
getTipCoffeeOption,
resolveTipCoffeeType,
TIP_COFFEE_OPTIONS,
} from "../tip_coffee";
describe("tip coffee configuration", () => {
it("resolves supported types case-insensitively", () => {
expect(resolveTipCoffeeType(" Small ")).toBe("small");
expect(resolveTipCoffeeType("MEDIUM")).toBe("medium");
expect(resolveTipCoffeeType("large")).toBe("large");
});
it("rejects missing and unsupported types", () => {
expect(resolveTipCoffeeType(null)).toBeNull();
expect(resolveTipCoffeeType("espresso")).toBeNull();
});
it("provides the configured plan and fallback price for each type", () => {
expect(getTipCoffeeOption("small")).toMatchObject({
amountCents: 499,
displayName: "Velvet Espresso",
image: { src: "/images/tip/small.jpg", width: 736, height: 736 },
planId: "tip_coffee_usd_4_99",
});
expect(getTipCoffeeOption("medium")).toMatchObject({
amountCents: 999,
displayName: "Gilded Heart",
image: { src: "/images/tip/medium.png", width: 1024, height: 1024 },
planId: "tip_coffee_usd_9_99",
});
expect(getTipCoffeeOption("large")).toMatchObject({
amountCents: 1999,
displayName: "Crown Blossom",
image: { src: "/images/tip/large.png", width: 1024, height: 1024 },
planId: "tip_coffee_usd_19_99",
});
});
it("lists all tiers in display order and defaults to medium", () => {
expect(TIP_COFFEE_OPTIONS.map(({ type }) => type)).toEqual([
"small",
"medium",
"large",
]);
expect(DEFAULT_TIP_COFFEE_TYPE).toBe("medium");
});
it("builds canonical tip paths", () => {
expect(buildTipCoffeePath("small")).toBe("/tip?coffee_type=small");
expect(buildTipCoffeePath("medium")).toBe("/tip?coffee_type=medium");
expect(buildTipCoffeePath("large")).toBe("/tip?coffee_type=large");
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
buildTipGiftPath,
normalizeTipGiftParam,
} from "../tip_gift";
describe("tip gift navigation", () => {
it("normalizes optional gift query values", () => {
expect(normalizeTipGiftParam(" coffee ")).toBe("coffee");
expect(normalizeTipGiftParam(" ")).toBeNull();
expect(normalizeTipGiftParam(null)).toBeNull();
});
it("builds canonical category and plan return paths", () => {
expect(
buildTipGiftPath({ category: "coffee", planId: "gift_1" }),
).toBe("/tip?category=coffee&planId=gift_1");
});
it("omits missing selection values without restoring coffee_type", () => {
expect(buildTipGiftPath({ category: null, planId: null })).toBe("/tip");
});
});
-86
View File
@@ -1,86 +0,0 @@
import { ROUTES } from "@/router/routes";
export const TIP_COFFEE_TYPE_PARAM = "coffee_type";
export const DEFAULT_TIP_COFFEE_TYPE = "medium";
export type TipCoffeeType = "small" | "medium" | "large";
export interface TipCoffeeOption {
readonly type: TipCoffeeType;
readonly amountCents: number;
readonly displayName: string;
readonly image: {
readonly src: string;
readonly width: number;
readonly height: number;
};
readonly planId: string;
}
const TIP_COFFEE_OPTION_BY_TYPE: Record<TipCoffeeType, TipCoffeeOption> = {
small: {
type: "small",
amountCents: 499,
displayName: "Velvet Espresso",
image: {
src: "/images/tip/small.jpg",
width: 736,
height: 736,
},
planId: "tip_coffee_usd_4_99",
},
medium: {
type: "medium",
amountCents: 999,
displayName: "Gilded Heart",
image: {
src: "/images/tip/medium.png",
width: 1024,
height: 1024,
},
planId: "tip_coffee_usd_9_99",
},
large: {
type: "large",
amountCents: 1999,
displayName: "Crown Blossom",
image: {
src: "/images/tip/large.png",
width: 1024,
height: 1024,
},
planId: "tip_coffee_usd_19_99",
},
};
export const TIP_COFFEE_OPTIONS: readonly TipCoffeeOption[] = [
TIP_COFFEE_OPTION_BY_TYPE.small,
TIP_COFFEE_OPTION_BY_TYPE.medium,
TIP_COFFEE_OPTION_BY_TYPE.large,
];
export function resolveTipCoffeeType(
value: string | null | undefined,
): TipCoffeeType | null {
const normalized = value?.trim().toLowerCase();
if (
normalized === "small" ||
normalized === "medium" ||
normalized === "large"
) {
return normalized;
}
return null;
}
export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption {
return TIP_COFFEE_OPTION_BY_TYPE[type];
}
export function buildTipCoffeePath(
type: TipCoffeeType,
basePath: string = ROUTES.tip,
): string {
const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type });
return `${basePath}?${params.toString()}`;
}
+31
View File
@@ -0,0 +1,31 @@
import { ROUTES } from "@/router/routes";
export const TIP_GIFT_CATEGORY_PARAM = "category";
export const TIP_GIFT_PLAN_ID_PARAM = "planId";
export interface TipGiftSelection {
category: string | null;
planId: string | null;
}
export function normalizeTipGiftParam(
value: string | null | undefined,
): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
}
export function buildTipGiftPath(
selection: TipGiftSelection,
basePath: string = ROUTES.tip,
): string {
const params = new URLSearchParams();
if (selection.category) {
params.set(TIP_GIFT_CATEGORY_PARAM, selection.category);
}
if (selection.planId) {
params.set(TIP_GIFT_PLAN_ID_PARAM, selection.planId);
}
const query = params.toString();
return query ? `${basePath}?${query}` : basePath;
}
@@ -8,10 +8,10 @@ import {
import { import {
createTestPaymentMachine, createTestPaymentMachine,
giftCatalog,
lifetimePlan, lifetimePlan,
monthlyPlan, monthlyPlan,
quarterlyPlan, quarterlyPlan,
tipPlan,
} from "./payment-machine.test-utils"; } from "./payment-machine.test-utils";
describe("payment catalog flow", () => { describe("payment catalog flow", () => {
@@ -31,25 +31,76 @@ describe("payment catalog flow", () => {
}); });
it("loads the tip catalog and disables auto renew", async () => { it("loads the tip catalog and disables auto renew", async () => {
const loadCatalog = vi.fn(); const loadGiftProducts = vi.fn();
const refreshCatalog = vi.fn();
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ createTestPaymentMachine({
refreshedPlans: PaymentPlansResponseSchema.parse({ plans: [tipPlan] }), onLoadGiftProducts: loadGiftProducts,
onLoadCatalog: loadCatalog,
onRefreshCatalog: refreshCatalog,
}), }),
).start(); ).start();
actor.send({ type: "PaymentInit", catalog: "tip" }); actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
await waitFor(actor, (snapshot) => snapshot.matches("ready")); await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(loadCatalog).toHaveBeenCalledWith("tip"); expect(loadGiftProducts).toHaveBeenCalledWith("elio");
expect(refreshCatalog).toHaveBeenCalledWith("tip");
expect(actor.getSnapshot().context).toMatchObject({ expect(actor.getSnapshot().context).toMatchObject({
planCatalog: "tip", planCatalog: "tip",
selectedPlanId: tipPlan.planId, selectedGiftCategory: "coffee",
selectedPlanId: "tip_coffee_usd_4_99",
autoRenew: false, autoRenew: false,
}); });
expect(actor.getSnapshot().context.plans).toHaveLength(2);
actor.stop();
});
it("restores a valid first-category product and rejects another category", async () => {
const actor = createActor(createTestPaymentMachine()).start();
actor.send({
type: "PaymentInit",
catalog: "tip",
characterId: "elio",
category: "coffee",
planId: "tip_coffee_usd_9_99",
});
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.selectedPlanId).toBe(
"tip_coffee_usd_9_99",
);
actor.send({
type: "PaymentInit",
catalog: "tip",
characterId: "elio",
category: "flowers",
planId: "tip_flowers_usd_12_99",
});
await waitFor(
actor,
(snapshot) =>
snapshot.matches("ready") &&
snapshot.context.requestedGiftPlanId === null,
);
expect(actor.getSnapshot().context.selectedPlanId).toBe(
giftCatalog.plans[0]?.planId,
);
actor.stop();
});
it("clears gift prices after a catalog request fails and supports retry", async () => {
const actor = createActor(
createTestPaymentMachine({
giftProductsError: new Error("catalog unavailable"),
}),
).start();
actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context).toMatchObject({
plans: [],
giftProducts: [],
selectedPlanId: "",
errorMessage: "catalog unavailable",
});
actor.send({ type: "PaymentCatalogRetryRequested" });
await waitFor(actor, (snapshot) => snapshot.matches("loadingGiftProducts"));
actor.stop(); actor.stop();
}); });
@@ -4,10 +4,15 @@ import { fromPromise } from "xstate";
import { import {
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
CreatePaymentOrderResponseSchema, CreatePaymentOrderResponseSchema,
GiftProductsResponse,
GiftProductsResponseSchema,
type PayChannel, type PayChannel,
type PaymentOrderStatus,
PaymentOrderStatusResponseSchema, PaymentOrderStatusResponseSchema,
PaymentPlansResponse, PaymentPlansResponse,
PaymentPlansResponseSchema, PaymentPlansResponseSchema,
TipMessageResponse,
TipMessageResponseSchema,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import { paymentMachine } from "@/stores/payment/payment-machine"; import { paymentMachine } from "@/stores/payment/payment-machine";
import type { PaymentPlanCatalog } from "@/stores/payment/payment-state"; import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
@@ -77,9 +82,77 @@ export const quarterlyPlan = {
currency: "usd", currency: "usd",
}; };
export const giftCatalog = GiftProductsResponseSchema.parse({
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 2,
imageUrl: null,
},
{
category: "flowers",
name: "Flowers",
productCount: 1,
imageUrl: null,
},
],
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Velvet Espresso",
orderType: "tip",
tipType: "coffee_small",
category: "coffee",
characterId: "elio",
description: "A small coffee",
imageUrl: null,
amountCents: 499,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
{
planId: "tip_coffee_usd_9_99",
planName: "Golden Reserve",
orderType: "tip",
tipType: "coffee_medium",
category: "coffee",
characterId: "elio",
description: "A medium coffee",
imageUrl: null,
amountCents: 999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
{
planId: "tip_flowers_usd_12_99",
planName: "Rose Bouquet",
orderType: "tip",
tipType: "flowers",
category: "flowers",
characterId: "elio",
description: "A bouquet",
imageUrl: null,
amountCents: 1299,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
],
});
export const tipPlan = { export const tipPlan = {
planId: "tip_coffee_usd_4_99", planId: "tip_coffee_usd_4_99",
planName: "Small Coffee", planName: "Velvet Espresso",
orderType: "tip", orderType: "tip",
vipDays: null, vipDays: null,
dolAmount: null, dolAmount: null,
@@ -90,21 +163,35 @@ export const tipPlan = {
currency: "USD", currency: "USD",
}; };
const defaultTipMessage = TipMessageResponseSchema.parse({
orderId: "pay_test_001",
characterId: "elio",
planId: "tip_coffee_usd_4_99",
productName: "Velvet Espresso",
tipCount: 1,
poolIndex: 37,
message: "Thank you for the thoughtful gift.",
});
export function createTestPaymentMachine( export function createTestPaymentMachine(
overrides: Partial<{ overrides: Partial<{
cachedPlans: PaymentPlansResponse | null; cachedPlans: PaymentPlansResponse | null;
refreshedPlans: PaymentPlansResponse; refreshedPlans: PaymentPlansResponse;
refreshPlans: () => Promise<PaymentPlansResponse>; refreshPlans: () => Promise<PaymentPlansResponse>;
giftProducts: GiftProductsResponse;
giftProductsError: Error;
onLoadCatalog: (catalog: PaymentPlanCatalog) => void; onLoadCatalog: (catalog: PaymentPlanCatalog) => void;
onRefreshCatalog: (catalog: PaymentPlanCatalog) => void; onRefreshCatalog: (catalog: PaymentPlanCatalog) => void;
onLoadGiftProducts: (characterId: string) => void;
createOrderSpy: CreateOrderSpy; createOrderSpy: CreateOrderSpy;
createOrderError: Error; createOrderError: Error;
orderStatus: "pending" | "paid" | "failed"; orderStatus: PaymentOrderStatus;
orderStatuses: ("pending" | "paid" | "failed")[]; orderStatuses: PaymentOrderStatus[];
orderType: string; orderType: string;
orderPlanId: string; orderPlanId: string | null;
tipCount: number | null; tipMessage: TipMessageResponse;
thankYouMessage: string | null; tipMessageError: Error;
onLoadTipMessage: (orderId: string) => void;
}> = {}, }> = {},
) { ) {
const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>(); const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>();
@@ -133,6 +220,14 @@ export function createTestPaymentMachine(
); );
}, },
), ),
loadGiftProducts: fromPromise<
GiftProductsResponse,
{ characterId: string }
>(async ({ input }) => {
overrides.onLoadGiftProducts?.(input.characterId);
if (overrides.giftProductsError) throw overrides.giftProductsError;
return overrides.giftProducts ?? giftCatalog;
}),
createOrder: fromPromise<CreatePaymentOrderResponse, CreateOrderInput>( createOrder: fromPromise<CreatePaymentOrderResponse, CreateOrderInput>(
async ({ input }) => { async ({ input }) => {
createOrderSpy(input); createOrderSpy(input);
@@ -156,10 +251,16 @@ export function createTestPaymentMachine(
status, status,
orderType: overrides.orderType ?? "vip_monthly", orderType: overrides.orderType ?? "vip_monthly",
planId: overrides.orderPlanId ?? "vip_monthly", planId: overrides.orderPlanId ?? "vip_monthly",
tipCount: overrides.tipCount ?? null, creditsAdded: 0,
thankYouMessage: overrides.thankYouMessage ?? null,
}); });
}), }),
loadTipMessage: fromPromise<TipMessageResponse, { orderId: string }>(
async ({ input }) => {
overrides.onLoadTipMessage?.(input.orderId);
if (overrides.tipMessageError) throw overrides.tipMessageError;
return overrides.tipMessage ?? defaultTipMessage;
},
),
}, },
}); });
} }
@@ -65,8 +65,8 @@ describe("payment order flow", () => {
expect(actor.getSnapshot().context).toMatchObject({ expect(actor.getSnapshot().context).toMatchObject({
currentOrderId: "pay_test_001", currentOrderId: "pay_test_001",
orderStatus: "paid", orderStatus: "paid",
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
orderPollingStartedAt: null, orderPollingStartedAt: null,
launchNonce: 1, launchNonce: 1,
}); });
@@ -81,33 +81,46 @@ describe("payment order flow", () => {
actor.stop(); actor.stop();
}); });
it("stores a stable Tip success result and clears it on reset", async () => { it("loads a stable Tip message after payment and clears it on reset", async () => {
const loadTipMessage = vi.fn();
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ createTestPaymentMachine({
orderType: "tip", orderType: "tip",
orderPlanId: "tip_coffee_usd_9_99", orderPlanId: "tip_coffee_usd_9_99",
onLoadTipMessage: loadTipMessage,
tipMessage: {
orderId: "pay_test_001",
characterId: "elio",
planId: "tip_coffee_usd_9_99",
productName: "Golden Reserve",
tipCount: 2, tipCount: 2,
thankYouMessage: "You made my day.", poolIndex: 18,
message: "You made my day.",
},
}), }),
).start(); ).start();
await initialize(actor); await initializeTip(actor);
actor.send({ actor.send({
type: "PaymentCreateOrderSubmitted", type: "PaymentCreateOrderSubmitted",
recipientCharacterId: "maya-tan", recipientCharacterId: "elio",
}); });
await waitFor(actor, (snapshot) => snapshot.matches("paid")); await waitFor(actor, (snapshot) => snapshot.matches("paid"));
expect(loadTipMessage).toHaveBeenCalledWith("pay_test_001");
expect(actor.getSnapshot().context).toMatchObject({ expect(actor.getSnapshot().context).toMatchObject({
orderStatus: "paid", orderStatus: "paid",
tipMessage: expect.objectContaining({
tipCount: 2, tipCount: 2,
thankYouMessage: "You made my day.", message: "You made my day.",
}),
tipMessageError: null,
}); });
actor.send({ type: "PaymentReset" }); actor.send({ type: "PaymentReset" });
await waitFor(actor, (snapshot) => snapshot.matches("ready")); await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context).toMatchObject({ expect(actor.getSnapshot().context).toMatchObject({
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
}); });
actor.stop(); actor.stop();
}); });
@@ -116,19 +129,18 @@ describe("payment order flow", () => {
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ createTestPaymentMachine({
orderType: "tip", orderType: "tip",
tipCount: 3, orderPlanId: "tip_coffee_usd_4_99",
thankYouMessage: "Another warm coffee.",
}), }),
).start(); ).start();
await initialize(actor); await initializeTip(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" }); actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid")); await waitFor(actor, (snapshot) => snapshot.matches("paid"));
actor.send({ type: "PaymentCreateOrderSubmitted" }); actor.send({ type: "PaymentCreateOrderSubmitted" });
expect(actor.getSnapshot().matches("creatingOrder")).toBe(true); expect(actor.getSnapshot().matches("creatingOrder")).toBe(true);
expect(actor.getSnapshot().context).toMatchObject({ expect(actor.getSnapshot().context).toMatchObject({
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
}); });
actor.stop(); actor.stop();
}); });
@@ -137,11 +149,10 @@ describe("payment order flow", () => {
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ createTestPaymentMachine({
orderType: "tip", orderType: "tip",
tipCount: 4, orderPlanId: "tip_coffee_usd_4_99",
thankYouMessage: "That was so thoughtful.",
}), }),
).start(); ).start();
await initialize(actor); await initializeTip(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" }); actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid")); await waitFor(actor, (snapshot) => snapshot.matches("paid"));
@@ -149,8 +160,8 @@ describe("payment order flow", () => {
expect(actor.getSnapshot().matches("ready")).toBe(true); expect(actor.getSnapshot().matches("ready")).toBe(true);
expect(actor.getSnapshot().context).toMatchObject({ expect(actor.getSnapshot().context).toMatchObject({
payChannel: "ezpay", payChannel: "ezpay",
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
}); });
actor.stop(); actor.stop();
}); });
@@ -211,8 +222,8 @@ describe("payment order flow", () => {
currentOrderId: null, currentOrderId: null,
payParams: null, payParams: null,
orderStatus: null, orderStatus: null,
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
orderPollingStartedAt: null, orderPollingStartedAt: null,
errorMessage: null, errorMessage: null,
}); });
@@ -235,6 +246,48 @@ describe("payment order flow", () => {
actor.stop(); actor.stop();
}); });
it("moves expired orders to a dedicated terminal state", async () => {
const actor = createActor(
createTestPaymentMachine({ orderStatus: "expired" }),
).start();
await initialize(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("expired"));
expect(actor.getSnapshot().context).toMatchObject({
orderStatus: "expired",
payParams: null,
errorMessage:
"This payment order has expired. Please create a new order.",
});
actor.stop();
});
it("keeps paid state when the Tip message fails and retries only the message", async () => {
const loadTipMessage = vi.fn();
const actor = createActor(
createTestPaymentMachine({
orderType: "tip",
orderPlanId: "tip_coffee_usd_4_99",
tipMessageError: new Error("message unavailable"),
onLoadTipMessage: loadTipMessage,
}),
).start();
await initializeTip(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("tipMessageFailed"));
expect(actor.getSnapshot().context).toMatchObject({
orderStatus: "paid",
tipMessage: null,
tipMessageError: "message unavailable",
});
actor.send({ type: "PaymentTipMessageRetryRequested" });
await waitFor(actor, () => loadTipMessage.mock.calls.length === 2);
expect(actor.getSnapshot().context.orderStatus).toBe("paid");
actor.stop();
});
it("times out an old pending order", async () => { it("times out an old pending order", async () => {
const statusPollTimeout = vi const statusPollTimeout = vi
.spyOn(behaviorAnalytics, "statusPollTimeout") .spyOn(behaviorAnalytics, "statusPollTimeout")
@@ -336,3 +389,10 @@ async function initialize(
actor.send({ type: "PaymentInit" }); actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready")); await waitFor(actor, (snapshot) => snapshot.matches("ready"));
} }
async function initializeTip(
actor: ReturnType<typeof createActor<ReturnType<typeof createTestPaymentMachine>>>,
): Promise<void> {
actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
}
+88
View File
@@ -0,0 +1,88 @@
import {
PaymentPlanSchema,
type GiftProduct,
type GiftProductsResponse,
type PaymentPlan,
} from "@/data/schemas/payment";
import type { PaymentState } from "../payment-state";
export function giftProductToPaymentPlan(
product: GiftProduct,
): PaymentPlan {
return PaymentPlanSchema.parse({
planId: product.planId,
planName: product.planName,
orderType: product.orderType,
vipDays: null,
dolAmount: null,
creditBalance: 0,
amountCents: product.amountCents,
originalAmountCents: null,
dailyPriceCents: null,
currency: product.currency,
isFirstRechargeOffer: false,
mostPopular: false,
firstRechargeDiscountPercent: null,
promotionType: product.promotionType,
});
}
export function hydrateGiftProductsState(
response: GiftProductsResponse,
characterId: string,
restoredCategory: string | null,
restoredPlanId: string | null,
): Pick<
PaymentState,
| "plans"
| "giftCategories"
| "giftProducts"
| "selectedGiftCategory"
| "selectedPlanId"
| "requestedGiftCategory"
| "requestedGiftPlanId"
| "autoRenew"
| "isFirstRecharge"
| "errorMessage"
> {
const giftCategories = response.categories;
const giftProducts = response.plans.filter(
(product) => product.characterId === characterId,
);
const selectedGiftCategory = giftCategories[0]?.category ?? null;
const visibleProducts = selectedGiftCategory
? giftProducts.filter(
(product) => product.category === selectedGiftCategory,
)
: [];
const canRestoreSelection =
restoredCategory === selectedGiftCategory &&
visibleProducts.some((product) => product.planId === restoredPlanId);
const selectedPlanId = canRestoreSelection
? (restoredPlanId ?? "")
: (visibleProducts[0]?.planId ?? "");
return {
plans: visibleProducts.map(giftProductToPaymentPlan),
giftCategories,
giftProducts,
selectedGiftCategory,
selectedPlanId,
requestedGiftCategory: null,
requestedGiftPlanId: null,
autoRenew: false,
isFirstRecharge: false,
errorMessage: null,
};
}
export function getSelectedGiftProduct(
context: Pick<PaymentState, "giftProducts" | "selectedPlanId">,
): GiftProduct | null {
return (
context.giftProducts.find(
(product) => product.planId === context.selectedPlanId,
) ?? null
);
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./catalog"; export * from "./catalog";
export * from "./gift";
export * from "./order"; export * from "./order";
+2 -2
View File
@@ -25,8 +25,8 @@ export function resetOrderState(): Partial<PaymentState> {
currentOrderPlanId: null, currentOrderPlanId: null,
payParams: null, payParams: null,
orderStatus: null, orderStatus: null,
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
orderPollingStartedAt: null, orderPollingStartedAt: null,
errorMessage: null, errorMessage: null,
}; };
@@ -0,0 +1,30 @@
import { fromPromise } from "xstate";
import type {
GiftProductsResponse,
TipMessageResponse,
} from "@/data/schemas/payment";
import { getPaymentRepository } from "@/data/repositories/payment_repository";
import { Result } from "@/utils/result";
export const loadGiftProductsActor = fromPromise<
GiftProductsResponse,
{ characterId: string }
>(async ({ input }) => {
if (!input.characterId) throw new Error("Missing gift recipient character.");
const result = await getPaymentRepository().getGiftProducts(
input.characterId,
);
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const loadTipMessageActor = fromPromise<
TipMessageResponse,
{ orderId: string }
>(async ({ input }) => {
if (!input.orderId) throw new Error("Missing paid Tip order id.");
const result = await getPaymentRepository().getTipMessage(input.orderId);
if (Result.isErr(result)) throw result.error;
return result.data;
});
+4 -4
View File
@@ -21,10 +21,10 @@ export const refreshPaymentPlansActor = fromPromise<
{ catalog: PaymentPlanCatalog } { catalog: PaymentPlanCatalog }
>(async ({ input }) => { >(async ({ input }) => {
const paymentRepo = getPaymentRepository(); const paymentRepo = getPaymentRepository();
const result = if (input.catalog === "tip") {
input.catalog === "tip" throw new Error("Gift catalogs must use the gift products actor.");
? await paymentRepo.getTipPlans() }
: await paymentRepo.getPlans(); const result = await paymentRepo.getPlans();
if (Result.isErr(result)) throw result.error; if (Result.isErr(result)) throw result.error;
return result.data; return result.data;
}); });
+115 -23
View File
@@ -1,50 +1,76 @@
import type { DoneActorEvent, ErrorActorEvent } from "xstate"; import type { DoneActorEvent, ErrorActorEvent } from "xstate";
import type { PaymentPlansResponse } from "@/data/schemas/payment"; import type {
GiftProductsResponse,
PaymentPlansResponse,
} from "@/data/schemas/payment";
import { import {
consumeFirstRechargeState, consumeFirstRechargeState,
hydrateGiftProductsState,
hydratePlansResponseState, hydratePlansResponseState,
refreshPlansResponseState, refreshPlansResponseState,
selectPlanState, selectPlanState,
} from "../helper/catalog"; } from "../helper";
import { import { resetOrderState, toPaymentErrorMessage } from "../helper/order";
resetOrderState, import type { PaymentEvent } from "../payment-events";
toPaymentErrorMessage, import type { PaymentState } from "../payment-state";
} from "../helper/order";
import { import {
basePaymentMachineSetup, basePaymentMachineSetup,
createPaymentActorActionSetup, createPaymentActorActionSetup,
} from "./setup"; } from "./setup";
const initializeCatalogAction = basePaymentMachineSetup.assign( function initializeCatalogState(
({ context, event }) => { context: PaymentState,
if (event.type !== "PaymentInit") return {}; event: Extract<PaymentEvent, { type: "PaymentInit" }>,
return { ): Partial<PaymentState> {
planCatalog: event.catalog ?? context.planCatalog,
...(event.payChannel ? { payChannel: event.payChannel } : {}),
};
},
);
const refreshCatalogAction = basePaymentMachineSetup.assign(
({ context, event }) => {
if (event.type !== "PaymentInit") return {};
const planCatalog = event.catalog ?? context.planCatalog; const planCatalog = event.catalog ?? context.planCatalog;
const giftCharacterId =
planCatalog === "tip"
? (event.characterId ?? context.giftCharacterId)
: null;
const catalogChanged = planCatalog !== context.planCatalog; const catalogChanged = planCatalog !== context.planCatalog;
const characterChanged = giftCharacterId !== context.giftCharacterId;
const selectionChanged =
planCatalog === "tip" &&
((event.category ?? null) !== context.selectedGiftCategory ||
(event.planId ?? null) !== (context.selectedPlanId || null));
return { return {
planCatalog, planCatalog,
...(event.payChannel ? { payChannel: event.payChannel } : {}), ...(event.payChannel ? { payChannel: event.payChannel } : {}),
...(catalogChanged giftCharacterId,
requestedGiftCategory:
planCatalog === "tip" ? (event.category ?? null) : null,
requestedGiftPlanId:
planCatalog === "tip" ? (event.planId ?? null) : null,
...(catalogChanged || characterChanged || selectionChanged
? { ? {
...resetOrderState(),
plans: [], plans: [],
giftCategories: [],
giftProducts: [],
selectedGiftCategory: null,
selectedPlanId: "", selectedPlanId: "",
isFirstRecharge: false, isFirstRecharge: false,
autoRenew: planCatalog !== "tip", autoRenew: planCatalog !== "tip",
} }
: {}), : {}),
}; };
}, }
const initializeCatalogAction = basePaymentMachineSetup.assign(
({ context, event }) =>
event.type === "PaymentInit"
? initializeCatalogState(context, event)
: {},
);
const refreshCatalogAction = basePaymentMachineSetup.assign(
({ context, event }) =>
event.type === "PaymentInit"
? initializeCatalogState(context, event)
: {},
); );
const selectPlanAction = basePaymentMachineSetup.assign( const selectPlanAction = basePaymentMachineSetup.assign(
@@ -105,6 +131,12 @@ export const catalogMachineSetup = basePaymentMachineSetup.extend({
resetOrder: resetOrderAction, resetOrder: resetOrderAction,
consumeFirstRecharge: consumeFirstRechargeAction, consumeFirstRecharge: consumeFirstRechargeAction,
}, },
guards: {
isTipInit: ({ context, event }) =>
event.type === "PaymentInit" &&
(event.catalog ?? context.planCatalog) === "tip",
isTipCatalog: ({ context }) => context.planCatalog === "tip",
},
}); });
const cachedPlansDoneSetup = const cachedPlansDoneSetup =
@@ -113,6 +145,8 @@ const cachedPlansDoneSetup =
>(); >();
const plansDoneSetup = const plansDoneSetup =
createPaymentActorActionSetup<DoneActorEvent<PaymentPlansResponse>>(); createPaymentActorActionSetup<DoneActorEvent<PaymentPlansResponse>>();
const giftProductsDoneSetup =
createPaymentActorActionSetup<DoneActorEvent<GiftProductsResponse>>();
const plansErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>(); const plansErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>();
const hydrateCachedPlansAction = cachedPlansDoneSetup.assign( const hydrateCachedPlansAction = cachedPlansDoneSetup.assign(
@@ -130,16 +164,42 @@ const refreshPlansAction = plansDoneSetup.assign(({ context, event }) =>
refreshPlansResponseState(event.output, context.selectedPlanId), refreshPlansResponseState(event.output, context.selectedPlanId),
); );
const hydrateGiftProductsAction = giftProductsDoneSetup.assign(
({ context, event }) =>
hydrateGiftProductsState(
event.output,
context.giftCharacterId ?? "",
context.requestedGiftCategory ?? context.selectedGiftCategory,
context.requestedGiftPlanId ?? context.selectedPlanId,
),
);
const applyPlansErrorAction = plansErrorSetup.assign(({ event }) => ({ const applyPlansErrorAction = plansErrorSetup.assign(({ event }) => ({
errorMessage: toPaymentErrorMessage(event.error), errorMessage: toPaymentErrorMessage(event.error),
})); }));
const applyGiftProductsErrorAction = plansErrorSetup.assign(({ event }) => ({
plans: [],
giftCategories: [],
giftProducts: [],
selectedGiftCategory: null,
selectedPlanId: "",
errorMessage: toPaymentErrorMessage(event.error),
}));
export const idleState = catalogMachineSetup.createStateConfig({ export const idleState = catalogMachineSetup.createStateConfig({
on: { on: {
PaymentInit: { PaymentInit: [
{
guard: "isTipInit",
target: "loadingGiftProducts",
actions: "initializeCatalog",
},
{
target: "loadingCachedPlans", target: "loadingCachedPlans",
actions: "initializeCatalog", actions: "initializeCatalog",
}, },
],
}, },
}); });
@@ -190,12 +250,44 @@ export const refreshingPlansState = catalogMachineSetup.createStateConfig({
}, },
}); });
export const loadingGiftProductsState =
catalogMachineSetup.createStateConfig({
invoke: {
src: "loadGiftProducts",
input: ({ context }) => ({
characterId: context.giftCharacterId ?? "",
}),
onDone: {
target: "ready",
actions: hydrateGiftProductsAction,
},
onError: {
target: "ready",
actions: applyGiftProductsErrorAction,
},
},
});
export const readyState = catalogMachineSetup.createStateConfig({ export const readyState = catalogMachineSetup.createStateConfig({
on: { on: {
PaymentInit: { PaymentInit: [
{
guard: "isTipInit",
target: "loadingGiftProducts",
actions: "refreshCatalog",
},
{
target: "refreshingPlans", target: "refreshingPlans",
actions: "refreshCatalog", actions: "refreshCatalog",
}, },
],
PaymentCatalogRetryRequested: [
{
guard: "isTipCatalog",
target: "loadingGiftProducts",
},
{ target: "refreshingPlans" },
],
PaymentPlanSelected: { actions: "selectPlan" }, PaymentPlanSelected: { actions: "selectPlan" },
PaymentPayChannelChanged: { actions: "changePayChannel" }, PaymentPayChannelChanged: { actions: "changePayChannel" },
PaymentAutoRenewChanged: { actions: "changeAutoRenew" }, PaymentAutoRenewChanged: { actions: "changeAutoRenew" },
+130 -6
View File
@@ -3,6 +3,7 @@ import type { DoneActorEvent, ErrorActorEvent } from "xstate";
import type { import type {
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
TipMessageResponse,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import { behaviorAnalytics } from "@/lib/analytics"; import { behaviorAnalytics } from "@/lib/analytics";
@@ -18,6 +19,7 @@ import {
idleState, idleState,
loadingCachedPlansState, loadingCachedPlansState,
loadingPlansState, loadingPlansState,
loadingGiftProductsState,
readyState, readyState,
refreshingPlansState, refreshingPlansState,
} from "./catalog-flow"; } from "./catalog-flow";
@@ -47,8 +49,8 @@ const applyReturnedOrderAction = catalogMachineSetup.assign(({ event }) => {
currentOrderPlanId: null, currentOrderPlanId: null,
payParams: null, payParams: null,
orderStatus: "pending", orderStatus: "pending",
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
orderPollingStartedAt: event.createdAt ?? Date.now(), orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null, errorMessage: null,
}; };
@@ -77,6 +79,8 @@ const pollOrderDoneSetup =
createPaymentActorActionSetup< createPaymentActorActionSetup<
DoneActorEvent<PaymentOrderStatusResponse> DoneActorEvent<PaymentOrderStatusResponse>
>(); >();
const tipMessageDoneSetup =
createPaymentActorActionSetup<DoneActorEvent<TipMessageResponse>>();
const orderErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>(); const orderErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>();
const trackCreateOrderSuccessAction = createOrderDoneSetup.createAction( const trackCreateOrderSuccessAction = createOrderDoneSetup.createAction(
@@ -99,8 +103,8 @@ const applyCreateOrderSuccessAction = createOrderDoneSetup.assign(
currentOrderPlanId: context.selectedPlanId, currentOrderPlanId: context.selectedPlanId,
payParams: event.output.payParams, payParams: event.output.payParams,
orderStatus: "pending", orderStatus: "pending",
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
orderPollingStartedAt: Date.now(), orderPollingStartedAt: Date.now(),
errorMessage: null, errorMessage: null,
launchNonce: context.launchNonce + 1, launchNonce: context.launchNonce + 1,
@@ -138,8 +142,8 @@ const trackPollTimeoutAction = pollOrderDoneSetup.createAction(
const applyPaidOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ const applyPaidOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
orderStatus: event.output.status, orderStatus: event.output.status,
tipCount: event.output.tipCount, tipMessage: null,
thankYouMessage: event.output.thankYouMessage, tipMessageError: null,
orderPollingStartedAt: null, orderPollingStartedAt: null,
errorMessage: null, errorMessage: null,
})); }));
@@ -150,6 +154,13 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
errorMessage: "Payment failed or was cancelled.", errorMessage: "Payment failed or was cancelled.",
})); }));
const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
orderStatus: event.output.status,
payParams: null,
orderPollingStartedAt: null,
errorMessage: "This payment order has expired. Please create a new order.",
}));
const applyTimedOutOrderAction = pollOrderDoneSetup.assign({ const applyTimedOutOrderAction = pollOrderDoneSetup.assign({
orderStatus: "failed", orderStatus: "failed",
orderPollingStartedAt: null, orderPollingStartedAt: null,
@@ -161,6 +172,18 @@ const applyPendingOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
errorMessage: null, errorMessage: null,
})); }));
const applyTipMessageSuccessAction = tipMessageDoneSetup.assign(
({ event }) => ({
tipMessage: event.output,
tipMessageError: null,
}),
);
const applyTipMessageErrorAction = orderErrorSetup.assign(({ event }) => ({
tipMessage: null,
tipMessageError: toPaymentErrorMessage(event.error),
}));
const creatingOrderState = paymentMachineSetup.createStateConfig({ const creatingOrderState = paymentMachineSetup.createStateConfig({
entry: "trackCreateOrderStart", entry: "trackCreateOrderStart",
invoke: { invoke: {
@@ -193,11 +216,22 @@ const pollingOrderState = paymentMachineSetup.createStateConfig({
src: "pollOrderStatus", src: "pollOrderStatus",
input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }), input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }),
onDone: [ onDone: [
{
guard: ({ context, event }) =>
event.output.status === "paid" && context.planCatalog === "tip",
target: "loadingTipMessage",
actions: [trackPollUpdateAction, applyPaidOrderAction],
},
{ {
guard: ({ event }) => event.output.status === "paid", guard: ({ event }) => event.output.status === "paid",
target: "paid", target: "paid",
actions: [trackPollUpdateAction, applyPaidOrderAction], actions: [trackPollUpdateAction, applyPaidOrderAction],
}, },
{
guard: ({ event }) => event.output.status === "expired",
target: "expired",
actions: [trackPollUpdateAction, applyExpiredOrderAction],
},
{ {
guard: ({ event }) => event.output.status === "failed", guard: ({ event }) => event.output.status === "failed",
target: "failed", target: "failed",
@@ -239,6 +273,45 @@ const waitingForPaymentState = paymentMachineSetup.createStateConfig({
}, },
}); });
const loadingTipMessageState = paymentMachineSetup.createStateConfig({
invoke: {
src: "loadTipMessage",
input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }),
onDone: {
target: "paid",
actions: applyTipMessageSuccessAction,
},
onError: {
target: "tipMessageFailed",
actions: applyTipMessageErrorAction,
},
},
on: {
PaymentReset: {
target: "ready",
actions: "resetOrder",
},
},
});
const tipMessageFailedState = paymentMachineSetup.createStateConfig({
on: {
PaymentTipMessageRetryRequested: { target: "loadingTipMessage" },
PaymentPlanSelected: {
target: "ready",
actions: "selectPlan",
},
PaymentPayChannelChanged: {
target: "ready",
actions: "changePayChannel",
},
PaymentReset: {
target: "ready",
actions: "resetOrder",
},
},
});
const paidState = paymentMachineSetup.createStateConfig({ const paidState = paymentMachineSetup.createStateConfig({
on: { on: {
PaymentPlanSelected: { PaymentPlanSelected: {
@@ -269,6 +342,42 @@ const paidState = paymentMachineSetup.createStateConfig({
const failedState = paymentMachineSetup.createStateConfig({ const failedState = paymentMachineSetup.createStateConfig({
on: { on: {
PaymentPlanSelected: {
target: "ready",
actions: "selectPlan",
},
PaymentPayChannelChanged: {
target: "ready",
actions: "changePayChannel",
},
PaymentCreateOrderSubmitted: [
{
guard: "canCreateOrder",
target: "creatingOrder",
actions: "resetOrder",
},
],
PaymentErrorCleared: {
target: "ready",
actions: "clearPaymentError",
},
PaymentReset: {
target: "ready",
actions: "resetOrder",
},
},
});
const expiredState = paymentMachineSetup.createStateConfig({
on: {
PaymentPlanSelected: {
target: "ready",
actions: "selectPlan",
},
PaymentPayChannelChanged: {
target: "ready",
actions: "changePayChannel",
},
PaymentCreateOrderSubmitted: [ PaymentCreateOrderSubmitted: [
{ {
guard: "canCreateOrder", guard: "canCreateOrder",
@@ -293,15 +402,30 @@ export const paymentRootStateConfig = paymentMachineSetup.createStateConfig({
idle: idleState, idle: idleState,
loadingCachedPlans: loadingCachedPlansState, loadingCachedPlans: loadingCachedPlansState,
loadingPlans: loadingPlansState, loadingPlans: loadingPlansState,
loadingGiftProducts: loadingGiftProductsState,
refreshingPlans: refreshingPlansState, refreshingPlans: refreshingPlansState,
ready: readyState, ready: readyState,
creatingOrder: creatingOrderState, creatingOrder: creatingOrderState,
pollingOrder: pollingOrderState, pollingOrder: pollingOrderState,
waitingForPayment: waitingForPaymentState, waitingForPayment: waitingForPaymentState,
loadingTipMessage: loadingTipMessageState,
tipMessageFailed: tipMessageFailedState,
paid: paidState, paid: paidState,
failed: failedState, failed: failedState,
expired: expiredState,
}, },
on: { on: {
PaymentInit: [
{
guard: "isTipInit",
target: ".loadingGiftProducts",
actions: "refreshCatalog",
},
{
target: ".refreshingPlans",
actions: "refreshCatalog",
},
],
PaymentFirstRechargeConsumed: { actions: "consumeFirstRecharge" }, PaymentFirstRechargeConsumed: { actions: "consumeFirstRecharge" },
PaymentReturned: { PaymentReturned: {
target: ".pollingOrder", target: ".pollingOrder",
+3
View File
@@ -6,6 +6,7 @@ import {
createPaymentOrderActor, createPaymentOrderActor,
pollPaymentOrderStatusActor, pollPaymentOrderStatusActor,
} from "./actors/order"; } from "./actors/order";
import { loadGiftProductsActor, loadTipMessageActor } from "./actors/gifts";
import { import {
loadCachedPaymentPlansActor, loadCachedPaymentPlansActor,
refreshPaymentPlansActor, refreshPaymentPlansActor,
@@ -19,6 +20,8 @@ export const basePaymentMachineSetup = setup({
actors: { actors: {
loadCachedPlans: loadCachedPaymentPlansActor, loadCachedPlans: loadCachedPaymentPlansActor,
refreshPlans: refreshPaymentPlansActor, refreshPlans: refreshPaymentPlansActor,
loadGiftProducts: loadGiftProductsActor,
loadTipMessage: loadTipMessageActor,
createOrder: createPaymentOrderActor, createOrder: createPaymentOrderActor,
pollOrderStatus: pollPaymentOrderStatusActor, pollOrderStatus: pollPaymentOrderStatusActor,
}, },
+16 -5
View File
@@ -13,7 +13,11 @@ import type {
export interface PaymentContextState { export interface PaymentContextState {
status: string; status: string;
planCatalog: MachineContext["planCatalog"];
plans: MachineContext["plans"]; plans: MachineContext["plans"];
giftCategories: MachineContext["giftCategories"];
giftProducts: MachineContext["giftProducts"];
selectedGiftCategory: string | null;
isFirstRecharge: MachineContext["isFirstRecharge"]; isFirstRecharge: MachineContext["isFirstRecharge"];
selectedPlanId: string; selectedPlanId: string;
payChannel: MachineContext["payChannel"]; payChannel: MachineContext["payChannel"];
@@ -22,11 +26,12 @@ export interface PaymentContextState {
currentOrderId: string | null; currentOrderId: string | null;
payParams: Record<string, unknown> | null; payParams: Record<string, unknown> | null;
orderStatus: MachineContext["orderStatus"]; orderStatus: MachineContext["orderStatus"];
tipCount: number | null; tipMessage: MachineContext["tipMessage"];
thankYouMessage: string | null; tipMessageError: string | null;
errorMessage: string | null; errorMessage: string | null;
launchNonce: number; launchNonce: number;
isLoadingPlans: boolean; isLoadingPlans: boolean;
isLoadingTipMessage: boolean;
isCreatingOrder: boolean; isCreatingOrder: boolean;
isPollingOrder: boolean; isPollingOrder: boolean;
isPaid: boolean; isPaid: boolean;
@@ -63,7 +68,11 @@ export function usePaymentSelector<T>(
function selectPaymentState(state: PaymentSnapshot): PaymentContextState { function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
return { return {
status: String(state.value), status: String(state.value),
planCatalog: state.context.planCatalog,
plans: state.context.plans, plans: state.context.plans,
giftCategories: state.context.giftCategories,
giftProducts: state.context.giftProducts,
selectedGiftCategory: state.context.selectedGiftCategory,
isFirstRecharge: state.context.isFirstRecharge, isFirstRecharge: state.context.isFirstRecharge,
selectedPlanId: state.context.selectedPlanId, selectedPlanId: state.context.selectedPlanId,
payChannel: state.context.payChannel, payChannel: state.context.payChannel,
@@ -72,17 +81,19 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
currentOrderId: state.context.currentOrderId, currentOrderId: state.context.currentOrderId,
payParams: state.context.payParams, payParams: state.context.payParams,
orderStatus: state.context.orderStatus, orderStatus: state.context.orderStatus,
tipCount: state.context.tipCount, tipMessage: state.context.tipMessage,
thankYouMessage: state.context.thankYouMessage, tipMessageError: state.context.tipMessageError,
errorMessage: state.context.errorMessage, errorMessage: state.context.errorMessage,
launchNonce: state.context.launchNonce, launchNonce: state.context.launchNonce,
isLoadingPlans: isLoadingPlans:
state.matches("loadingCachedPlans") || state.matches("loadingCachedPlans") ||
state.matches("loadingPlans") || state.matches("loadingPlans") ||
state.matches("loadingGiftProducts") ||
state.matches("refreshingPlans"), state.matches("refreshingPlans"),
isLoadingTipMessage: state.matches("loadingTipMessage"),
isCreatingOrder: state.matches("creatingOrder"), isCreatingOrder: state.matches("creatingOrder"),
isPollingOrder: isPollingOrder:
state.matches("pollingOrder") || state.matches("waitingForPayment"), state.matches("pollingOrder") || state.matches("waitingForPayment"),
isPaid: state.matches("paid"), isPaid: state.context.orderStatus === "paid",
}; };
} }
+5
View File
@@ -7,6 +7,9 @@ export type PaymentEvent =
type: "PaymentInit"; type: "PaymentInit";
payChannel?: PayChannel; payChannel?: PayChannel;
catalog?: PaymentPlanCatalog; catalog?: PaymentPlanCatalog;
characterId?: string;
category?: string | null;
planId?: string | null;
} }
| { type: "PaymentPlanSelected"; planId: string } | { type: "PaymentPlanSelected"; planId: string }
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel } | { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
@@ -19,5 +22,7 @@ export type PaymentEvent =
| { type: "PaymentReturned"; orderId: string; createdAt?: number } | { type: "PaymentReturned"; orderId: string; createdAt?: number }
| { type: "PaymentLaunchFailed"; errorMessage: string } | { type: "PaymentLaunchFailed"; errorMessage: string }
| { type: "PaymentFirstRechargeConsumed" } | { type: "PaymentFirstRechargeConsumed" }
| { type: "PaymentCatalogRetryRequested" }
| { type: "PaymentTipMessageRetryRequested" }
| { type: "PaymentErrorCleared" } | { type: "PaymentErrorCleared" }
| { type: "PaymentReset" }; | { type: "PaymentReset" };
+19 -4
View File
@@ -1,8 +1,11 @@
/** Context shared by the default and Tip payment catalogs. */ /** Context shared by the default and Tip payment catalogs. */
import type { import type {
GiftCategory,
GiftProduct,
PayChannel, PayChannel,
PaymentOrderStatus, PaymentOrderStatus,
PaymentPlan, PaymentPlan,
TipMessageResponse,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
export type PaymentPlanCatalog = "default" | "tip"; export type PaymentPlanCatalog = "default" | "tip";
@@ -10,6 +13,12 @@ export type PaymentPlanCatalog = "default" | "tip";
export interface PaymentState { export interface PaymentState {
planCatalog: PaymentPlanCatalog; planCatalog: PaymentPlanCatalog;
plans: readonly PaymentPlan[]; plans: readonly PaymentPlan[];
giftCategories: readonly GiftCategory[];
giftProducts: readonly GiftProduct[];
giftCharacterId: string | null;
selectedGiftCategory: string | null;
requestedGiftCategory: string | null;
requestedGiftPlanId: string | null;
isFirstRecharge: boolean; isFirstRecharge: boolean;
selectedPlanId: string; selectedPlanId: string;
payChannel: PayChannel; payChannel: PayChannel;
@@ -19,8 +28,8 @@ export interface PaymentState {
currentOrderPlanId: string | null; currentOrderPlanId: string | null;
payParams: Record<string, unknown> | null; payParams: Record<string, unknown> | null;
orderStatus: PaymentOrderStatus | null; orderStatus: PaymentOrderStatus | null;
tipCount: number | null; tipMessage: TipMessageResponse | null;
thankYouMessage: string | null; tipMessageError: string | null;
orderPollingStartedAt: number | null; orderPollingStartedAt: number | null;
errorMessage: string | null; errorMessage: string | null;
launchNonce: number; launchNonce: number;
@@ -29,6 +38,12 @@ export interface PaymentState {
export const initialState: PaymentState = { export const initialState: PaymentState = {
planCatalog: "default", planCatalog: "default",
plans: [], plans: [],
giftCategories: [],
giftProducts: [],
giftCharacterId: null,
selectedGiftCategory: null,
requestedGiftCategory: null,
requestedGiftPlanId: null,
isFirstRecharge: false, isFirstRecharge: false,
selectedPlanId: "", selectedPlanId: "",
payChannel: "stripe", payChannel: "stripe",
@@ -38,8 +53,8 @@ export const initialState: PaymentState = {
currentOrderPlanId: null, currentOrderPlanId: null,
payParams: null, payParams: null,
orderStatus: null, orderStatus: null,
tipCount: null, tipMessage: null,
thankYouMessage: null, tipMessageError: null,
orderPollingStartedAt: null, orderPollingStartedAt: null,
errorMessage: null, errorMessage: null,
launchNonce: 0, launchNonce: 0,