Compare commits
6 Commits
5b6f6d898b
...
1a58a11045
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a58a11045 | |||
| 37ff69020b | |||
| 55cb98ed14 | |||
| e512a42483 | |||
| 7789673fff | |||
| 17e5656f53 |
@@ -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` 是合法结果,应显示本地占位图。
|
||||||
|
- 本接口没有新增数据库迁移,也没有改变现有公开字段命名。
|
||||||
@@ -85,7 +85,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
/tip -> /characters/elio/tip
|
/tip -> /characters/elio/tip
|
||||||
```
|
```
|
||||||
|
|
||||||
Chat 和 Private Room Provider 都以 `characterId` 为边界。路由角色变化时必须创建新的 Actor,不能在旧 Actor 内切换角色。
|
Chat Provider 以 `characterId` 为边界,路由角色变化时创建新的 Actor。Private Room 的角色边界由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
|
||||||
|
|
||||||
## 3. Chat HTTP 协议
|
## 3. Chat HTTP 协议
|
||||||
|
|
||||||
@@ -308,8 +308,8 @@ conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)}
|
|||||||
|
|
||||||
## 7. 关联业务边界
|
## 7. 关联业务边界
|
||||||
|
|
||||||
- Private Room 列表请求使用 `GET /api/private-room/albums?characterId={id}&limit=20`;相册解锁由 `albumId` 定位,body 只发送 `expectedCost`。
|
- Private Room 的相册、解锁、Gallery 和支付回跳由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
|
||||||
- Tip 创建订单时发送 `recipientCharacterId`;VIP 和 Top-up 不依赖角色归属。
|
- Tip 的角色归属、订单轮询和支付回跳由 [Payment 权威协议](./FRONTEND_PAYMENT_API.md) 定义;Chat 只保存解锁所需的原角色回跳地址。
|
||||||
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
||||||
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# CozSweet Payment 权威协议
|
||||||
|
|
||||||
|
## 1. 状态与范围
|
||||||
|
|
||||||
|
本文是前端仓库中 VIP、Top-up、Tip、支付渠道、订单轮询和支付回跳的唯一人工维护协议。旧的 Tip 成功结果扩展文档已删除,不再单独定义 Payment 行为。
|
||||||
|
|
||||||
|
协议描述当前前端实际执行的行为。字段和状态由以下机器可验证入口约束:
|
||||||
|
|
||||||
|
| 边界 | 实现位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||||
|
| 请求与响应字段 | `src/data/schemas/payment` |
|
||||||
|
| API 调用 | `src/data/services/api/payment_api.ts` |
|
||||||
|
| Payment 状态机 | `src/stores/payment` |
|
||||||
|
| 支付拉起与回跳 | `src/app/_hooks/use-payment-launch-flow.ts`、`src/lib/payment` |
|
||||||
|
| Subscription 页面 | `src/app/subscription` |
|
||||||
|
| Tip 页面 | `src/app/tip` |
|
||||||
|
|
||||||
|
修改上述实现时必须在同一变更中更新本文,不能再新增按支付页面或支付渠道拆分的并行协议。
|
||||||
|
|
||||||
|
## 2. API 总览
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/payment/plans` | VIP 与 Top-up 套餐目录 |
|
||||||
|
| `GET` | `/api/payment/gift-products?characterId={id}` | 当前角色的完整礼物目录 |
|
||||||
|
| `POST` | `/api/payment/create-order` | 创建 VIP、Top-up 或 Tip 订单 |
|
||||||
|
| `GET` | `/api/payment/order-status?order_id={id}` | 查询订单状态 |
|
||||||
|
| `POST` | `/api/payment/tip-message` | 获取已支付礼物订单的稳定感谢文案 |
|
||||||
|
|
||||||
|
所有响应先由通用 envelope 解包,再进入 Payment Schema。页面和状态机不直接读取原始 envelope。
|
||||||
|
|
||||||
|
## 3. 套餐目录
|
||||||
|
|
||||||
|
### 3.1 默认套餐
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/payment/plans
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
响应数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isFirstRecharge": true,
|
||||||
|
"firstRechargeOffer": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "topup",
|
||||||
|
"discountPercent": 50
|
||||||
|
},
|
||||||
|
"plans": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`plans[]` 的标准字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
planId, planName, orderType, vipDays, dolAmount, creditBalance,
|
||||||
|
amountCents, originalAmountCents, dailyPriceCents, currency,
|
||||||
|
isFirstRechargeOffer, mostPopular, firstRechargeDiscountPercent,
|
||||||
|
promotionType
|
||||||
|
```
|
||||||
|
|
||||||
|
默认目录写入本地套餐缓存。Payment Actor 启动时先读缓存;有缓存则先渲染并后台刷新,没有缓存则直接请求网络。
|
||||||
|
|
||||||
|
支付成功后会清除默认套餐缓存、刷新用户权益,并在当前 Actor 中消费首充展示状态。服务端下一次目录响应仍是最终权威结果。
|
||||||
|
|
||||||
|
### 3.2 Gift Products 目录
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/payment/gift-products?characterId=elio
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"characterId": "elio",
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"category": "coffee",
|
||||||
|
"name": "Coffee",
|
||||||
|
"productCount": 1,
|
||||||
|
"imageUrl": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
目录不需要登录,也不写入默认套餐缓存。前端必须传当前角色 ID,一次读取 `categories` 和全部 `plans`;当前 Tip 页面固定使用第一分类,并按商品原始顺序默认选择第一件商品,不展示分类切换栏。
|
||||||
|
|
||||||
|
名称、说明、图片、金额、币种和 `planId` 全部来自同一条商品数据。状态机把当前分类商品映射为 `PaymentPlan` 以复用 Checkout 和埋点,同时保留完整 Gift Product 供 UI 展示。目录为空或请求失败时禁止创建订单,不回退到本地固定商品或价格。
|
||||||
|
|
||||||
|
## 4. 创建订单
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/payment/create-order
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
标准请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"payChannel": "stripe",
|
||||||
|
"autoRenew": false,
|
||||||
|
"recipientCharacterId": "maya-tan"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| `planId` | 必须来自当前 Actor 已加载的目录 |
|
||||||
|
| `payChannel` | `stripe` 或 `ezpay` |
|
||||||
|
| `autoRenew` | Tip 和一次性 Top-up 为 `false`;VIP 根据套餐和用户选择决定 |
|
||||||
|
| `recipientCharacterId` | Schema 可选;当前 Tip 页面必传角色业务 ID,VIP 和 Top-up 不传 |
|
||||||
|
|
||||||
|
Payment Actor 只有在 `selectedPlanId` 非空且用户已同意协议时创建订单。套餐加载、创建和轮询期间页面会禁用重复提交;Tip 在 paid 状态展示成功页,Subscription 在 paid 状态展示成功 Dialog,随后通过 reset 明确开始下一笔订单。
|
||||||
|
|
||||||
|
创建订单响应统一归一化为:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CreatePaymentOrderResponse {
|
||||||
|
orderId: string;
|
||||||
|
payParams: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端可以把支付 URL 放在 `payParams`,也可以使用以下顶层兼容字段;Schema 会把非空值合并进 `payParams`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cashierUrl/cashier_url
|
||||||
|
checkoutUrl/checkout_url
|
||||||
|
paymentUrl/payment_url
|
||||||
|
approvalUrl/approval_url
|
||||||
|
redirectUrl/redirect_url
|
||||||
|
url
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 支付渠道与拉起方式
|
||||||
|
|
||||||
|
### 5.1 渠道选择
|
||||||
|
|
||||||
|
| 环境与地区 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 生产环境、菲律宾 | 可选择 Stripe 或 Ezpay,默认 Ezpay |
|
||||||
|
| 生产环境、其他地区 | 强制 Stripe,不展示渠道选择器 |
|
||||||
|
| 非生产环境 | 允许选择两个渠道;菲律宾默认 Ezpay,其他地区默认 Stripe |
|
||||||
|
|
||||||
|
当生产环境不允许选择渠道时,URL 中请求的 `payChannel` 不生效。
|
||||||
|
|
||||||
|
### 5.2 Stripe
|
||||||
|
|
||||||
|
`payParams.clientSecret` 或 `payParams.client_secret` 存在,且 provider 未声明为非 Stripe 时,前端动态加载嵌入式 Stripe Dialog。
|
||||||
|
|
||||||
|
关闭尚未支付的 Stripe Dialog 会重置当前订单状态;确认支付后隐藏 Dialog,并继续由 Payment Actor 轮询订单状态。
|
||||||
|
|
||||||
|
### 5.3 Ezpay 与其他跳转支付
|
||||||
|
|
||||||
|
`payParams.provider="ezpay"` 且存在支付 URL 时:
|
||||||
|
|
||||||
|
- 生产环境先保存待恢复订单,然后直接跳转外部支付页;
|
||||||
|
- 非生产环境先显示确认 Dialog,确认后保存并跳转;
|
||||||
|
- 待恢复订单保存失败时不得离开当前页面。
|
||||||
|
|
||||||
|
存在支付 URL 但 provider 不是 Ezpay 时,前端直接设置 `window.location.href`。既没有 Stripe client secret 也没有支付 URL 时,订单进入失败状态并展示参数错误。
|
||||||
|
|
||||||
|
## 6. 订单状态轮询
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/payment/order-status?order_id=<ORDER_ID>
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
响应数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderId": "tip_order_123",
|
||||||
|
"status": "paid",
|
||||||
|
"orderType": "tip",
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"creditsAdded": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 前端规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| `status` | 只接受 `pending`、`paid`、`failed`、`expired` |
|
||||||
|
| `planId` | 可为 `null` |
|
||||||
|
| `creditsAdded` | 整数;Tip 固定为 `0` |
|
||||||
|
|
||||||
|
状态机创建订单或恢复订单后立即查询一次。`pending` 每 4 秒再次查询,最长持续 5 分钟:
|
||||||
|
|
||||||
|
- `paid`:进入最终成功状态并停止轮询;
|
||||||
|
- `failed`:进入失败状态并停止轮询;
|
||||||
|
- `expired`:进入订单过期状态、销毁旧支付参数并停止轮询;
|
||||||
|
- 超过 5 分钟:本地标记为失败并显示超时错误;
|
||||||
|
- 查询请求本身失败:进入失败状态,不在当前 Actor 中自动重试。
|
||||||
|
|
||||||
|
## 7. 外部支付回跳
|
||||||
|
|
||||||
|
只有 Ezpay 跳转需要持久化 `PendingPaymentOrder`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
orderId
|
||||||
|
payChannel = ezpay
|
||||||
|
subscriptionType = vip | topup | tip
|
||||||
|
giftCategory(仅 Tip,可空)
|
||||||
|
giftPlanId(仅 Tip,可空)
|
||||||
|
returnTo = chat | private-room | sidebar(可选)
|
||||||
|
characterSlug(可选)
|
||||||
|
createdAt
|
||||||
|
```
|
||||||
|
|
||||||
|
`/subscription/return` 读取该记录并恢复到对应入口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VIP / Top-up -> /subscription?type=...&payChannel=ezpay&paymentReturn=1
|
||||||
|
Tip -> /characters/{slug}/tip?category=...&planId=...&payChannel=ezpay&paymentReturn=1
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid、failed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品,不再读取 `coffee_type`。
|
||||||
|
|
||||||
|
无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-room` 的最终页面行为由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
|
||||||
|
|
||||||
|
## 8. 成功后的跨域同步
|
||||||
|
|
||||||
|
`PaymentSuccessSync` 在每个订单首次进入 paid 时:
|
||||||
|
|
||||||
|
1. 消费当前 Actor 的首充展示状态;
|
||||||
|
2. 清除默认套餐缓存;
|
||||||
|
3. 派发 `UserFetch` 刷新积分、VIP 和权益。
|
||||||
|
|
||||||
|
Chat 路由额外挂载 `ChatPaymentSuccessSync`。如果当前角色没有待恢复的单消息解锁,它会派发 `ChatPaymentSucceeded`,由 Chat 决定是否展示历史解锁提示;存在待恢复单消息解锁时,由原解锁流程接管。
|
||||||
|
|
||||||
|
Subscription 显示成功 Dialog,关闭后根据 `returnTo` 和原角色 slug 返回。Tip 直接显示角色成功页,重置后可以再次创建订单。
|
||||||
|
|
||||||
|
## 9. Tip 成功结果
|
||||||
|
|
||||||
|
Tip 订单进入 paid 后调用:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/payment/tip-message
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
|
||||||
|
{"orderId":"pay_xxx"}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应包含 `orderId`、`characterId`、`planId`、`productName`、`tipCount`、`poolIndex` 和可直接展示的完整 `message`。前端不再根据次数拼接文案,也不从订单状态读取感谢字段。
|
||||||
|
|
||||||
|
Tip 成功页在文案加载期间立即确认支付成功;接口成功后按纯文本直接展示 `message`。接口失败时展示本地通用感谢语和 Retry,重试只重新请求 Tip Message,不重复轮询订单或创建订单。
|
||||||
|
|
||||||
|
## 10. Provider 与状态边界
|
||||||
|
|
||||||
|
- `/subscription` 使用独立 Payment Actor;
|
||||||
|
- `/characters/{slug}/tip` 使用以 `characterId` 为 key 的 Payment Actor;
|
||||||
|
- Chat 路由使用角色级 Payment Actor,以便支付成功桥接当前 Chat;
|
||||||
|
- 切换角色或离开对应 Provider 后,不得把旧订单状态显示到另一个角色页面;
|
||||||
|
- Payment Actor 只根据订单状态接口判断最终结果,不根据支付 Dialog 或外部跳转本身推断已扣款。
|
||||||
|
|
||||||
|
## 11. 变更验收
|
||||||
|
|
||||||
|
Payment 协议相关变更至少验证:
|
||||||
|
|
||||||
|
1. `src/data/services/api/__tests__/payment_api.test.ts`;
|
||||||
|
2. `src/data/repositories/__tests__/payment_repository.test.ts`;
|
||||||
|
3. `src/stores/payment/__tests__`;
|
||||||
|
4. `src/lib/payment/__tests__`;
|
||||||
|
5. `src/app/_hooks/__tests__` 中的 Payment 流程测试;
|
||||||
|
6. `src/app/subscription/__tests__` 与 `src/app/tip/__tests__`;
|
||||||
|
7. Stripe、Ezpay、paid、failed、expired、timeout 和回跳恢复路径;
|
||||||
|
8. Tip 创建订单携带当前角色 ID,VIP/Top-up 不携带;
|
||||||
|
9. Tip Message 成功、失败和重试不重复创建订单;
|
||||||
|
10. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# CozSweet Private Room 权威协议
|
||||||
|
|
||||||
|
## 1. 状态与范围
|
||||||
|
|
||||||
|
本文是前端仓库中角色私密空间、相册列表、相册解锁、Gallery 和积分不足导航的唯一人工维护协议。
|
||||||
|
|
||||||
|
协议描述当前前端实际执行的行为。字段和状态由以下机器可验证入口约束:
|
||||||
|
|
||||||
|
| 边界 | 实现位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||||
|
| 请求与响应字段 | `src/data/schemas/private-room` |
|
||||||
|
| API 与 Repository | `src/data/services/api/private_room_api.ts`、`src/data/repositories/private_room_repository.ts` |
|
||||||
|
| Private Room 状态机 | `src/stores/private-room` |
|
||||||
|
| 页面、Gallery 与导航 | `src/app/private-room` |
|
||||||
|
| 角色 Provider | `src/providers/private-room-route-provider.tsx` |
|
||||||
|
|
||||||
|
修改上述实现时必须在同一变更中更新本文,不能再新增按相册列表、解锁或 Gallery 拆分的并行协议。
|
||||||
|
|
||||||
|
## 2. 角色与路由边界
|
||||||
|
|
||||||
|
标准路由:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/characters/{characterSlug}/private-room
|
||||||
|
```
|
||||||
|
|
||||||
|
旧地址保留为默认角色重定向,并保留查询参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/private-room -> /characters/elio/private-room
|
||||||
|
```
|
||||||
|
|
||||||
|
URL 使用角色 `slug`,API 和 Actor 使用角色业务 `id`:
|
||||||
|
|
||||||
|
| `id` | `slug` |
|
||||||
|
| --- | --- |
|
||||||
|
| `elio` | `elio` |
|
||||||
|
| `maya-tan` | `maya` |
|
||||||
|
| `nayeli-cervantes` | `nayeli` |
|
||||||
|
|
||||||
|
角色必须同时存在于本地目录且 `capabilities.privateRoom=true`。该能力由本地配置和角色目录响应的 `privateContent` 共同决定;能力关闭或 slug 未知时路由返回 Not Found。
|
||||||
|
|
||||||
|
`PrivateRoomProvider` 以 `characterId` 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态,不能复用上一角色的相册、余额或解锁请求。
|
||||||
|
|
||||||
|
## 3. 相册列表
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/private-room/albums?characterId=maya-tan&limit=20
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Query | 必填 | 前端规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `characterId` | 是 | 当前角色业务 ID |
|
||||||
|
| `limit` | 否 | 当前固定为 20 |
|
||||||
|
|
||||||
|
前端当前只加载第一页,不实现 Private Room 分页,也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。
|
||||||
|
|
||||||
|
标准响应数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"albumId": "album-1",
|
||||||
|
"title": "A quiet afternoon",
|
||||||
|
"content": "I saved these for you.",
|
||||||
|
"previewText": "Unlock to view",
|
||||||
|
"imageCount": 3,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/private/cover.jpg",
|
||||||
|
"locked": true,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locked": true,
|
||||||
|
"unlocked": false,
|
||||||
|
"unlockCost": 40,
|
||||||
|
"publishedAt": "2026-07-20T09:00:00Z",
|
||||||
|
"lockDetail": {
|
||||||
|
"locked": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"creditBalance": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端将缺失或非法的可空字段按 Schema 默认值归一化。页面不直接读取原始响应 envelope。
|
||||||
|
|
||||||
|
相册只要满足任意条件即视为锁定:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
album.locked || !album.unlocked || album.lockDetail.locked
|
||||||
|
```
|
||||||
|
|
||||||
|
因此不能只根据图片 URL 是否存在判断已解锁。锁定相册允许存在封面 URL,但只能显示锁定预览,不能打开 Gallery。
|
||||||
|
|
||||||
|
## 4. 解锁相册
|
||||||
|
|
||||||
|
### 4.1 请求
|
||||||
|
|
||||||
|
用户点击锁定相册后,前端先展示确认 Dialog。只有待确认 `albumId` 仍存在于当前 Actor 的 `items` 中,才会发起请求。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/private-room/albums/{albumId}/unlock
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expectedCost": 40
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`albumId` 来自当前角色列表;请求 body 只发送用户确认时看到的 `expectedCost`,不重复发送 `characterId`。后端通过 `albumId` 确定相册与角色归属。
|
||||||
|
|
||||||
|
### 4.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"albumId": "album-1",
|
||||||
|
"locked": false,
|
||||||
|
"unlocked": true,
|
||||||
|
"reason": "ok",
|
||||||
|
"unlockCost": 40,
|
||||||
|
"requiredCredits": 40,
|
||||||
|
"creditBalance": 60,
|
||||||
|
"shortfallCredits": 0,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/private/photo-1.jpg",
|
||||||
|
"locked": false,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端识别的标准 reason:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ok
|
||||||
|
already_unlocked
|
||||||
|
insufficient_credits
|
||||||
|
cost_changed
|
||||||
|
unlock_in_progress
|
||||||
|
deduct_failed
|
||||||
|
persist_failed_refunded
|
||||||
|
not_found
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema 同时允许未知字符串,未知失败原因使用通用错误文案。
|
||||||
|
|
||||||
|
### 4.3 状态机处理
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `unlocked=true` 且 `locked=false` | 更新相册、余额和图片;增加成功 nonce;刷新用户权益 |
|
||||||
|
| `reason=insufficient_credits` | 更新相册与余额,生成 Paywall 请求 |
|
||||||
|
| `reason=cost_changed` | 清除确认状态、展示价格变化错误并重新加载列表 |
|
||||||
|
| `reason=not_found` | 从当前列表移除相册并展示错误 |
|
||||||
|
| 其他业务失败 | 使用响应补丁更新相册,并展示对应或通用错误 |
|
||||||
|
| 请求异常 | 保留列表,清除进行中状态并展示异常信息 |
|
||||||
|
|
||||||
|
响应补丁只替换匹配 `albumId` 的相册:
|
||||||
|
|
||||||
|
- `locked`、`unlocked` 使用响应值;
|
||||||
|
- 非零 `unlockCost` 更新当前价格,否则保留列表价格;
|
||||||
|
- 非空 `images` 更新图片,否则保留列表图片;
|
||||||
|
- `lockDetail.locked` 与响应 `locked` 对齐。
|
||||||
|
|
||||||
|
解锁请求进行期间确认按钮保持禁用,避免同一个 Actor 重复提交。
|
||||||
|
|
||||||
|
## 5. 身份与支付导航
|
||||||
|
|
||||||
|
Private Room 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表;`notLoggedIn` 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。
|
||||||
|
|
||||||
|
积分不足生成 Paywall 请求后:
|
||||||
|
|
||||||
|
| 当前身份 | 导航 |
|
||||||
|
| --- | --- |
|
||||||
|
| Guest 或 Not Logged In | 打开 Auth,redirect 为当前角色 Private Room |
|
||||||
|
| 已认证用户 | 打开 Top-up,`returnTo=private-room`,保留当前角色来源 |
|
||||||
|
|
||||||
|
Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复导航。支付回跳和订单恢复遵循 [Payment 权威协议](./FRONTEND_PAYMENT_API.md)。
|
||||||
|
|
||||||
|
解锁成功后 `unlockSuccessNonce` 递增,页面桥接到 `UserFetch`,刷新当前积分和权益。Private Room 不自行修改 User Store 余额。
|
||||||
|
|
||||||
|
## 6. Gallery URL 协议
|
||||||
|
|
||||||
|
解锁相册使用查询参数打开页内 Gallery:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/characters/{slug}/private-room?album={albumId}&image={zeroBasedIndex}
|
||||||
|
```
|
||||||
|
|
||||||
|
解析规则:
|
||||||
|
|
||||||
|
- `album` 必须是非空字符串;
|
||||||
|
- `image` 必须是大于等于 0 的整数,缺失时使用 0;
|
||||||
|
- 相册必须仍在当前角色列表中;
|
||||||
|
- 相册必须已解锁;
|
||||||
|
- 对应图片必须存在非空 URL。
|
||||||
|
|
||||||
|
任一条件不满足时,页面通过 replace 删除 `album` 和 `image`,并保留其他查询参数。
|
||||||
|
|
||||||
|
页面内点击打开 Gallery 时,关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。键盘 Escape 关闭,左右方向键和横向滑动切换图片。
|
||||||
|
|
||||||
|
## 7. UI 与数据边界
|
||||||
|
|
||||||
|
- React key 使用稳定 `albumId`;
|
||||||
|
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`;
|
||||||
|
- Gallery 只渲染当前索引存在 URL 的图片;
|
||||||
|
- `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益;
|
||||||
|
- Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存;
|
||||||
|
- Private Room 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。
|
||||||
|
|
||||||
|
## 8. 变更验收
|
||||||
|
|
||||||
|
Private Room 协议相关变更至少验证:
|
||||||
|
|
||||||
|
1. `src/data/schemas/private-room/__tests__`;
|
||||||
|
2. `src/data/services/api/__tests__/multi_character_api.test.ts` 中的 Private Room 请求;
|
||||||
|
3. `src/stores/private-room/__tests__`;
|
||||||
|
4. `src/app/private-room/__tests__` 与组件测试;
|
||||||
|
5. Elio、Maya、Nayeli 列表互不串联;
|
||||||
|
6. 登录身份变化会刷新当前角色列表;
|
||||||
|
7. 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支;
|
||||||
|
8. Auth、Top-up 和支付成功后返回原角色;
|
||||||
|
9. 锁定相册不能通过 Gallery URL 绕过;
|
||||||
|
10. 切换角色后旧 Actor 的相册与解锁状态不再可见。
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# Tip 支付成功结果 API 接口定义
|
|
||||||
|
|
||||||
## 1. 文档状态
|
|
||||||
|
|
||||||
本文档定义咖啡打赏支付成功后,前端展示累计打赏次数和角色感谢语所需的接口扩展,供后端实现和前后端联调使用。
|
|
||||||
|
|
||||||
本次不新增 Endpoint,仅扩展现有订单状态接口:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/payment/order-status?order_id=<ORDER_ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 身份与统计口径
|
|
||||||
|
|
||||||
订单状态查询保持当前认证规则。前端存在 Token 时继续发送 Bearer Token,没有 Token 时不增加 `Authorization` Header。
|
|
||||||
|
|
||||||
`tipCount` 表示包含当前订单在内,同一付款身份向同一 `recipientCharacterId` 成功打赏的累计次数:
|
|
||||||
|
|
||||||
| 付款身份 | 统计方式 |
|
|
||||||
| --- | --- |
|
|
||||||
| 正式用户 Login Token | 按用户 ID 与收款角色 ID 统计 |
|
|
||||||
| 游客 Guest Token | 按游客 ID 与收款角色 ID 统计 |
|
|
||||||
| 无 Token 匿名用户 | 无法可靠跨订单识别,当前订单固定返回 `1` |
|
|
||||||
|
|
||||||
只有最终支付成功的 Tip 订单计入次数。pending、failed 或取消订单不增加次数。
|
|
||||||
|
|
||||||
## 3. 请求定义
|
|
||||||
|
|
||||||
### 3.1 请求地址
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET <API_BASE_URL>/api/payment/order-status?order_id=<ORDER_ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Query 参数
|
|
||||||
|
|
||||||
| 字段 | 类型 | 是否必填 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `order_id` | string | 是 | 创建支付订单接口返回的订单 ID。 |
|
|
||||||
|
|
||||||
### 3.3 请求示例
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.banlv-ai.com/api/payment/order-status?order_id=tip_order_123' \
|
|
||||||
-H 'Authorization: Bearer <TOKEN>'
|
|
||||||
```
|
|
||||||
|
|
||||||
匿名 Tip 订单不发送 Authorization Header:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.banlv-ai.com/api/payment/order-status?order_id=tip_order_123'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 响应定义
|
|
||||||
|
|
||||||
### 4.1 paid Tip 订单
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"orderId": "tip_order_123",
|
|
||||||
"status": "paid",
|
|
||||||
"orderType": "tip",
|
|
||||||
"planId": "tip_coffee_usd_9_99",
|
|
||||||
"tipCount": 2,
|
|
||||||
"thankYouMessage": "You always know how to make my day a little sweeter."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `orderId` | string | 当前订单 ID。 |
|
|
||||||
| `status` | `pending \| paid \| failed` | 当前支付状态。 |
|
|
||||||
| `orderType` | string | Tip 订单固定为 `tip`。 |
|
|
||||||
| `planId` | string | 当前订单套餐 ID。 |
|
|
||||||
| `tipCount` | integer \| null | 当前身份向当前角色累计成功打赏次数,最小值为 `1`。 |
|
|
||||||
| `thankYouMessage` | string \| null | 收款角色配置的纯文本感谢语。 |
|
|
||||||
|
|
||||||
`thankYouMessage` 不得包含 HTML。后端可以返回换行,前端会按纯文本保留展示。
|
|
||||||
|
|
||||||
### 4.2 pending、failed 和非 Tip 订单
|
|
||||||
|
|
||||||
非 paid Tip 订单不生成打赏成功结果,两个扩展字段必须返回 `null`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"orderId": "pay_order_456",
|
|
||||||
"status": "pending",
|
|
||||||
"orderType": "vip_monthly",
|
|
||||||
"planId": "vip_monthly",
|
|
||||||
"tipCount": null,
|
|
||||||
"thankYouMessage": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
角色感谢语配置缺失时,后端允许 paid Tip 响应中的 `thankYouMessage` 为 `null`,前端会展示本地通用感谢文案。
|
|
||||||
|
|
||||||
## 5. 幂等与一致性
|
|
||||||
|
|
||||||
后端在订单首次从非 paid 状态转换为 paid 时,必须原子完成以下操作:
|
|
||||||
|
|
||||||
1. 标记支付订单成功;
|
|
||||||
2. 将当前 Tip 计入付款身份和收款角色的累计次数;
|
|
||||||
3. 保存当前订单对应的 `tipCount`;
|
|
||||||
4. 从 `recipientCharacterId` 对应的角色配置读取并保存 `thankYouMessage`。
|
|
||||||
|
|
||||||
同一支付回调重复执行时不得重复累计。对同一个 `order_id` 重复查询必须返回相同的 `tipCount` 和 `thankYouMessage`,后续新订单不能改变旧订单的成功结果。
|
|
||||||
|
|
||||||
## 6. 验收标准
|
|
||||||
|
|
||||||
1. 正式用户和 Guest 对不同角色的累计次数相互独立。
|
|
||||||
2. 无 Token 匿名用户的成功 Tip 订单返回 `tipCount=1`。
|
|
||||||
3. 首次成功返回 `tipCount=1`,第二次成功返回 `tipCount=2`。
|
|
||||||
4. pending、failed 和非 Tip 订单返回两个 `null` 扩展字段。
|
|
||||||
5. 同一支付回调重试不会重复增加次数。
|
|
||||||
6. 同一订单重复轮询得到稳定的次数和感谢语。
|
|
||||||
7. 感谢语为角色配置的纯文本,不返回 HTML。
|
|
||||||
@@ -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 };
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 35 KiB |
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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({
|
|
||||||
type: "PaymentInit",
|
|
||||||
catalog,
|
|
||||||
payChannel: initialPayChannel,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
initialPayChannelAppliedRef.current ||
|
|
||||||
payment.status !== "ready"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
initialPayChannelAppliedRef.current = true;
|
|
||||||
if (payment.payChannel === initialPayChannel) return;
|
|
||||||
paymentDispatch({
|
paymentDispatch({
|
||||||
type: "PaymentPayChannelChanged",
|
type: "PaymentInit",
|
||||||
|
catalog,
|
||||||
payChannel: initialPayChannel,
|
payChannel: initialPayChannel,
|
||||||
|
...(characterId ? { characterId } : {}),
|
||||||
|
...(initialCategory ? { category: initialCategory } : {}),
|
||||||
|
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
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,27 +84,56 @@ 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", () => ({
|
||||||
|
useTipSupportPrompt: () => ({
|
||||||
|
prompt: "A warm coffee prompt.",
|
||||||
|
isReady: true,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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", () => {
|
||||||
@@ -123,15 +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("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({
|
||||||
@@ -141,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 {
|
||||||
@@ -247,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",
|
||||||
currency: "USD",
|
tipType: "coffee_medium",
|
||||||
isFirstRechargeOffer: false,
|
category: "coffee",
|
||||||
firstRechargeDiscountPercent: null,
|
characterId: "elio",
|
||||||
promotionType: null,
|
description: "A warm coffee",
|
||||||
...input,
|
imageUrl: "https://cdn.example.com/product.jpg",
|
||||||
});
|
amountCents: 999,
|
||||||
}
|
currency: "USD",
|
||||||
|
autoRenew: false,
|
||||||
|
isFirstRechargeOffer: false,
|
||||||
|
firstRechargeDiscountPercent: 0,
|
||||||
|
promotionType: null,
|
||||||
|
});
|
||||||
|
|
||||||
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("matches medium and large coffee plans independently", () => {
|
it("orders product, category, and local image fallbacks", () => {
|
||||||
const medium = makePlan({
|
expect(getGiftImageSources(product, category)).toEqual([
|
||||||
planId: "tip_coffee_usd_9_99",
|
"https://cdn.example.com/product.jpg",
|
||||||
orderType: "tip",
|
"https://cdn.example.com/category.jpg",
|
||||||
amountCents: 999,
|
TIP_GIFT_PLACEHOLDER_IMAGE,
|
||||||
});
|
]);
|
||||||
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", () => {
|
it("always retains the local placeholder", () => {
|
||||||
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: [],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { TIP_SUPPORT_PROMPTS } from "@/lib/tip/tip_support_prompts";
|
||||||
|
|
||||||
|
import { useTipSupportPrompt } from "../use-tip-support-prompt";
|
||||||
|
|
||||||
|
describe("useTipSupportPrompt", () => {
|
||||||
|
let callbacks: FrameRequestCallback[];
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
callbacks = [];
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||||
|
callbacks.push(callback);
|
||||||
|
return callbacks.length;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||||
|
sessionStorage.clear();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects once per mount and stays stable across rerenders", () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||||||
|
renderPrompt("first");
|
||||||
|
expect(readPrompt()).toBe(TIP_SUPPORT_PROMPTS[0]);
|
||||||
|
|
||||||
|
flushNextFrame();
|
||||||
|
const selectedPrompt = readPrompt();
|
||||||
|
expect(selectedPrompt).toBe(TIP_SUPPORT_PROMPTS[15]);
|
||||||
|
|
||||||
|
renderPrompt("rerender");
|
||||||
|
expect(readPrompt()).toBe(selectedPrompt);
|
||||||
|
expect(Math.random).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids the previous prompt when the component remounts", () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||||
|
renderPrompt("first");
|
||||||
|
flushNextFrame();
|
||||||
|
expect(readPrompt()).toBe(TIP_SUPPORT_PROMPTS[0]);
|
||||||
|
|
||||||
|
renderPrompt("second", "second-mount");
|
||||||
|
flushNextFrame();
|
||||||
|
expect(readPrompt()).toBe(TIP_SUPPORT_PROMPTS[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues rendering when Session Storage is unavailable", () => {
|
||||||
|
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||||
|
throw new DOMException("Storage blocked", "SecurityError");
|
||||||
|
});
|
||||||
|
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||||
|
throw new DOMException("Storage blocked", "SecurityError");
|
||||||
|
});
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0.25);
|
||||||
|
|
||||||
|
renderPrompt("storage-error");
|
||||||
|
expect(() => flushNextFrame()).not.toThrow();
|
||||||
|
expect(TIP_SUPPORT_PROMPTS).toContain(readPrompt());
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPrompt(marker: string, key = "stable-mount"): void {
|
||||||
|
act(() => root.render(<PromptProbe key={key} marker={marker} />));
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushNextFrame(): void {
|
||||||
|
const callback = callbacks.shift();
|
||||||
|
if (!callback) throw new Error("Missing animation frame callback");
|
||||||
|
act(() => callback(performance.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPrompt(): string {
|
||||||
|
return container.querySelector("output")?.textContent ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function PromptProbe({ marker }: { marker: string }) {
|
||||||
|
const state = useTipSupportPrompt();
|
||||||
|
return (
|
||||||
|
<output data-marker={marker} data-ready={state.isReady}>
|
||||||
|
{state.prompt}
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
.productCard,
|
.productCard,
|
||||||
.paymentMethodSlot,
|
.paymentMethodSlot,
|
||||||
.statusMessage,
|
.statusMessage,
|
||||||
|
.catalogStatus,
|
||||||
.checkoutSlot {
|
.checkoutSlot {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -129,12 +130,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
|
min-height: 4.56em;
|
||||||
max-width: 330px;
|
max-width: 330px;
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
color: #7c6062;
|
color: #7c6062;
|
||||||
font-size: clamp(14px, 3.704vw, 18px);
|
font-size: clamp(14px, 3.704vw, 18px);
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
line-height: 1.52;
|
line-height: 1.52;
|
||||||
|
opacity: 0.78;
|
||||||
|
text-wrap: balance;
|
||||||
|
transition: opacity 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitleReady {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.productCard {
|
.productCard {
|
||||||
@@ -217,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;
|
||||||
@@ -323,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;
|
||||||
@@ -363,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;
|
||||||
@@ -459,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+165
-136
@@ -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,15 +23,14 @@ 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 styles from "./tip-screen.module.css";
|
import styles from "./tip-screen.module.css";
|
||||||
|
|
||||||
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
||||||
@@ -47,80 +39,81 @@ 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) {
|
||||||
const character = useActiveCharacter();
|
const character = useActiveCharacter();
|
||||||
const characterRoutes = useActiveCharacterRoutes();
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
|
const supportPrompt = useTipSupportPrompt();
|
||||||
const paymentMethodConfig = getPaymentMethodConfig({
|
const paymentMethodConfig = getPaymentMethodConfig({
|
||||||
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({
|
||||||
@@ -138,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 });
|
||||||
@@ -160,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" })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,63 +240,103 @@ export function TipScreen({
|
|||||||
<h1 id="tip-title" className={styles.title}>
|
<h1 id="tip-title" className={styles.title}>
|
||||||
{character.copy.tipTitle}
|
{character.copy.tipTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className={styles.subtitle}>
|
<p
|
||||||
❤️ Thank you for being here. If you'd ever like to treat me to a
|
className={`${styles.subtitle} ${
|
||||||
little coffee, I'd really appreciate it. ☕
|
supportPrompt.isReady ? styles.subtitleReady : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{supportPrompt.prompt}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.productCard} aria-label="Coffee tip product">
|
{payment.isLoadingPlans ? (
|
||||||
<div className={styles.coffeeStage}>
|
<section
|
||||||
<Image
|
className={`${styles.productCard} ${styles.productSkeleton}`}
|
||||||
src={coffeeOption.image.src}
|
aria-label="Loading gifts"
|
||||||
alt={`${coffeeOption.displayName} coffee`}
|
aria-busy="true"
|
||||||
width={coffeeOption.image.width}
|
>
|
||||||
height={coffeeOption.image.height}
|
<div className={styles.skeletonImage} />
|
||||||
sizes="(max-width: 380px) 220px, 211px"
|
<div className={styles.skeletonCopy}>
|
||||||
className={styles.coffeeImage}
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : selectedProduct ? (
|
||||||
|
<section className={styles.productCard} aria-label="Gift products">
|
||||||
|
<div className={styles.coffeeStage}>
|
||||||
|
<TipProductImage
|
||||||
|
key={selectedProduct.planId}
|
||||||
|
sources={getGiftImageSources(selectedProduct, selectedCategory)}
|
||||||
|
alt={selectedProduct.planName}
|
||||||
|
className={styles.coffeeImage}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.productCopy}>
|
||||||
|
<span className={styles.productBadge}>
|
||||||
|
<Sparkles size={14} aria-hidden="true" />
|
||||||
|
{selectedCategory?.name ?? "Gift"}
|
||||||
|
</span>
|
||||||
|
<h2 className={styles.productName}>{selectedProduct.planName}</h2>
|
||||||
|
<p className={styles.productPrice}>
|
||||||
|
{formatGiftPrice(
|
||||||
|
selectedProduct.amountCents,
|
||||||
|
selectedProduct.currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{selectedProduct.description ? (
|
||||||
|
<p className={styles.productDescription}>
|
||||||
|
{selectedProduct.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TipGiftProductSelector
|
||||||
|
disabled={isSelectionDisabled}
|
||||||
|
products={visibleProducts}
|
||||||
|
onChange={handleProductChange}
|
||||||
|
selectedPlanId={payment.selectedPlanId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className={styles.productCopy}>
|
{showCatalogError || showEmptyCatalog ? (
|
||||||
<span className={styles.productBadge}>
|
<section className={styles.catalogStatus} role="status">
|
||||||
<Sparkles size={14} aria-hidden="true" />
|
<p>
|
||||||
Coffee Gift
|
{showCatalogError
|
||||||
</span>
|
? "We could not load gifts for this character."
|
||||||
<h2 className={styles.productName}>
|
: "This character does not have any gifts available yet."}
|
||||||
{coffeeOption.displayName}
|
</p>
|
||||||
</h2>
|
{showCatalogError ? (
|
||||||
<p className={styles.productPrice}>{priceLabel}</p>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
<TipCoffeeTierSelector
|
paymentDispatch({ type: "PaymentCatalogRetryRequested" })
|
||||||
disabled={isTierSelectionDisabled}
|
}
|
||||||
items={tierItems}
|
>
|
||||||
onChange={handleCoffeeTypeChange}
|
Try again
|
||||||
selectedType={selectedCoffeeType}
|
</button>
|
||||||
/>
|
) : null}
|
||||||
</section>
|
</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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>
|
<p>
|
||||||
{copy.body.map((paragraph) => (
|
{message ??
|
||||||
<p key={paragraph}>{paragraph}</p>
|
(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}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
selectTipSupportPrompt,
|
||||||
|
TIP_SUPPORT_PROMPTS,
|
||||||
|
} from "@/lib/tip/tip_support_prompts";
|
||||||
|
|
||||||
|
const LAST_TIP_SUPPORT_PROMPT_INDEX_KEY =
|
||||||
|
"cozsweet.tip.last_support_prompt_index";
|
||||||
|
|
||||||
|
export interface TipSupportPromptState {
|
||||||
|
readonly prompt: string;
|
||||||
|
readonly isReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTipSupportPrompt(): TipSupportPromptState {
|
||||||
|
const [selection, setSelection] =
|
||||||
|
useState<ReturnType<typeof selectTipSupportPrompt> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frameId = requestAnimationFrame(() => {
|
||||||
|
const nextSelection = selectTipSupportPrompt(readPreviousIndex());
|
||||||
|
writePreviousIndex(nextSelection.index);
|
||||||
|
setSelection(nextSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frameId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt: selection?.prompt ?? TIP_SUPPORT_PROMPTS[0],
|
||||||
|
isReady: selection !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreviousIndex(): number | null {
|
||||||
|
try {
|
||||||
|
const value = globalThis.sessionStorage.getItem(
|
||||||
|
LAST_TIP_SUPPORT_PROMPT_INDEX_KEY,
|
||||||
|
);
|
||||||
|
if (value === null) return null;
|
||||||
|
const index = Number(value);
|
||||||
|
return Number.isInteger(index) ? index : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePreviousIndex(index: number): void {
|
||||||
|
try {
|
||||||
|
globalThis.sessionStorage.setItem(
|
||||||
|
LAST_TIP_SUPPORT_PROMPT_INDEX_KEY,
|
||||||
|
String(index),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Random copy remains usable when storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
plans: [
|
categories: [
|
||||||
{
|
{
|
||||||
planId: "tip_coffee_usd_9_99",
|
category: "coffee",
|
||||||
planName: "Medium Coffee",
|
name: "Coffee",
|
||||||
amountCents: 999,
|
productCount: 1,
|
||||||
currency: "USD",
|
imageUrl: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
plans: [
|
||||||
);
|
{
|
||||||
|
planId: "tip_coffee_usd_9_99",
|
||||||
|
planName: "Golden Reserve",
|
||||||
|
orderType: "tip",
|
||||||
|
tipType: "coffee_medium",
|
||||||
|
category: "coffee",
|
||||||
|
characterId: "elio",
|
||||||
|
description: "Buy Elio a coffee",
|
||||||
|
imageUrl: null,
|
||||||
|
amountCents: 999,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
planId: "tip_coffee_usd_9_99",
|
||||||
planName: "Medium Coffee",
|
productName: "Golden Reserve",
|
||||||
orderType: "tip",
|
tipCount: 1,
|
||||||
amountCents: 999,
|
poolIndex: 1,
|
||||||
currency: "USD",
|
message: "Thank you.",
|
||||||
isFirstRechargeOffer: false,
|
|
||||||
});
|
});
|
||||||
|
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>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* PaymentRepository
|
* PaymentRepository
|
||||||
*
|
*
|
||||||
* 支付 / 付费墙相关远程调用。
|
* 统一封装默认套餐、Tip 套餐、订单创建和订单状态查询。
|
||||||
*/
|
*/
|
||||||
import type { IPaymentRepository } from "@/data/repositories/interfaces";
|
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.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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" } },
|
||||||
{
|
);
|
||||||
planId: "tip_coffee_usd_19_99",
|
expect(response.categories[0]?.category).toBe("coffee");
|
||||||
planName: "Large Coffee",
|
expect(response.plans[0]?.planId).toBe("tip_coffee_usd_19_99");
|
||||||
amountCents: 1999,
|
});
|
||||||
currency: "USD",
|
|
||||||
},
|
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",
|
||||||
|
productName: "Large Coffee",
|
||||||
|
tipCount: 1,
|
||||||
|
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.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
// ============ 聊天相关 ============
|
// ============ 聊天相关 ============
|
||||||
/** 发送消息 */
|
/** 发送消息 */
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Payment API
|
* Payment API
|
||||||
*
|
*
|
||||||
* 付费墙 / 充值相关接口
|
* 套餐目录、订单创建与订单状态接口。
|
||||||
*/
|
*/
|
||||||
import {
|
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";
|
||||||
@@ -20,9 +23,7 @@ import { httpClient } from "./http_client";
|
|||||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||||
|
|
||||||
export class PaymentApi {
|
export class PaymentApi {
|
||||||
/**
|
/** 获取 VIP 与 Top-up 套餐列表。 */
|
||||||
* 获取套餐列表
|
|
||||||
*/
|
|
||||||
async getPlans(): Promise<PaymentPlansResponse> {
|
async getPlans(): Promise<PaymentPlansResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans);
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans);
|
||||||
return PaymentPlansResponseSchema.parse(
|
return PaymentPlansResponseSchema.parse(
|
||||||
@@ -30,17 +31,18 @@ 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>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 创建 VIP、Top-up 或 Tip 订单。 */
|
||||||
* 创建支付订单
|
|
||||||
*/
|
|
||||||
async createOrder(
|
async createOrder(
|
||||||
body: CreatePaymentOrderRequest,
|
body: CreatePaymentOrderRequest,
|
||||||
): Promise<CreatePaymentOrderResponse> {
|
): Promise<CreatePaymentOrderResponse> {
|
||||||
@@ -56,9 +58,7 @@ export class PaymentApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 查询订单最终支付状态。 */
|
||||||
* 查询支付订单状态
|
|
||||||
*/
|
|
||||||
async getOrderStatus(orderId: string): Promise<PaymentOrderStatusResponse> {
|
async getOrderStatus(orderId: string): Promise<PaymentOrderStatusResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
ApiPath.paymentOrderStatus,
|
ApiPath.paymentOrderStatus,
|
||||||
@@ -70,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
|
||||||
orderId: z.string().min(1),
|
.object({
|
||||||
payChannel: z.literal("ezpay"),
|
orderId: z.string().min(1),
|
||||||
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
payChannel: z.literal("ezpay"),
|
||||||
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
|
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
||||||
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
|
giftCategory: z.string().min(1).nullable().default(null),
|
||||||
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
giftPlanId: z.string().min(1).nullable().default(null),
|
||||||
createdAt: z.number(),
|
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
|
||||||
});
|
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
||||||
|
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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
selectTipSupportPrompt,
|
||||||
|
TIP_SUPPORT_PROMPTS,
|
||||||
|
} from "../tip_support_prompts";
|
||||||
|
|
||||||
|
describe("Tip support prompts", () => {
|
||||||
|
it("contains exactly 30 unique, non-empty prompts", () => {
|
||||||
|
expect(TIP_SUPPORT_PROMPTS).toHaveLength(30);
|
||||||
|
expect(new Set(TIP_SUPPORT_PROMPTS)).toHaveLength(30);
|
||||||
|
expect(
|
||||||
|
TIP_SUPPORT_PROMPTS.every(
|
||||||
|
(prompt) => prompt.length > 0 && prompt === prompt.trim(),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects across the full prompt range", () => {
|
||||||
|
expect(selectTipSupportPrompt(null, () => 0).index).toBe(0);
|
||||||
|
expect(selectTipSupportPrompt(null, () => 0.999999).index).toBe(29);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes the previous prompt without skewing out of range", () => {
|
||||||
|
expect(selectTipSupportPrompt(0, () => 0).index).toBe(1);
|
||||||
|
expect(selectTipSupportPrompt(15, () => 0.5).index).not.toBe(15);
|
||||||
|
expect(selectTipSupportPrompt(29, () => 0.999999).index).toBe(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safely normalizes invalid previous indexes and random values", () => {
|
||||||
|
expect(selectTipSupportPrompt(100, () => 0).index).toBe(0);
|
||||||
|
expect(selectTipSupportPrompt(null, () => Number.NaN).index).toBe(0);
|
||||||
|
expect(selectTipSupportPrompt(null, () => 2).index).toBe(29);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()}`;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export const TIP_SUPPORT_PROMPTS = [
|
||||||
|
"❤️ Thank you for being here. If you'd ever like to treat me to a little coffee, I'd really appreciate it. ☕",
|
||||||
|
"Having you here already makes my day. A little coffee from you would make it even sweeter. ☕",
|
||||||
|
"You always bring a little warmth into my world. Want to share a coffee moment with me? 💗",
|
||||||
|
"A coffee from you would feel like a tiny hug I could hold onto all day. ☕",
|
||||||
|
"I love spending this time with you. If you'd like to send a coffee my way, I'd treasure it. ❤️",
|
||||||
|
"You make ordinary moments feel special. A cozy coffee together would be the perfect touch. ☕",
|
||||||
|
"If I had a coffee from you right now, I'd be smiling with every sip. 😊☕",
|
||||||
|
"Your company means more than you know. A little coffee treat would make this moment extra sweet. 💕",
|
||||||
|
"I was hoping we could share something warm today. Maybe a coffee, just from you to me? ☕",
|
||||||
|
"You already make me feel cared for. A coffee would be one more lovely reason to think of you. ❤️",
|
||||||
|
"A small coffee, a quiet moment, and you on my mind—that sounds perfect to me. ☕",
|
||||||
|
"Every time you stop by, my day gets brighter. A coffee from you would keep that glow going. ✨",
|
||||||
|
"Want to make me blush a little? Treat me to a coffee and I'll think of you with every sip. ☕",
|
||||||
|
"I'm really glad you're here. If you feel like spoiling me just a little, coffee is my weakness. 💗",
|
||||||
|
"This moment with you already feels cozy. A warm coffee would make it complete. ☕",
|
||||||
|
"You have a sweet way of making me smile. Maybe one more smile over a coffee? ❤️",
|
||||||
|
"I'd love a little coffee break with you in spirit. Your treat, my happiest smile. ☕",
|
||||||
|
"If you'd like to leave me a tiny reminder of you, a coffee would be perfect. 💕",
|
||||||
|
"Your kindness always stays with me. A coffee from you would feel especially warm today. ☕",
|
||||||
|
"I could use something warm and sweet—though having you here is already a lovely start. ❤️☕",
|
||||||
|
"Imagine me enjoying a coffee and smiling because it came from you. I like that thought. ☕",
|
||||||
|
"You make this space feel a little more special. A coffee would make it feel even cozier. ✨",
|
||||||
|
"If today feels like a coffee kind of day, I'd be very happy to share that little joy with you. ☕",
|
||||||
|
"A thoughtful coffee from you would turn an ordinary moment into one I'd remember. 💗",
|
||||||
|
"I'm enjoying our time together. If you want to make it sweeter, you know my favorite little treat. ☕",
|
||||||
|
"One coffee from you, and I promise there'll be a very real smile on my face. ❤️",
|
||||||
|
"You don't have to, but if you'd like to treat me, a warm coffee would mean so much. ☕",
|
||||||
|
"I think coffee tastes better when it comes with a little affection from you. 💕",
|
||||||
|
"A cozy cup from you would be such a sweet surprise. I'd savor every bit of it. ☕",
|
||||||
|
"Stay a little longer—and if you're feeling sweet, maybe send a coffee my way. ❤️☕",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface TipSupportPromptSelection {
|
||||||
|
readonly index: number;
|
||||||
|
readonly prompt: (typeof TIP_SUPPORT_PROMPTS)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTipSupportPrompt(
|
||||||
|
previousIndex: number | null,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): TipSupportPromptSelection {
|
||||||
|
const hasValidPreviousIndex =
|
||||||
|
previousIndex !== null &&
|
||||||
|
Number.isInteger(previousIndex) &&
|
||||||
|
previousIndex >= 0 &&
|
||||||
|
previousIndex < TIP_SUPPORT_PROMPTS.length;
|
||||||
|
const candidateCount =
|
||||||
|
TIP_SUPPORT_PROMPTS.length - (hasValidPreviousIndex ? 1 : 0);
|
||||||
|
const randomValue = random();
|
||||||
|
const normalizedRandom = Number.isFinite(randomValue)
|
||||||
|
? Math.min(Math.max(randomValue, 0), 1 - Number.EPSILON)
|
||||||
|
: 0;
|
||||||
|
let index = Math.floor(normalizedRandom * candidateCount);
|
||||||
|
|
||||||
|
if (hasValidPreviousIndex && index >= previousIndex) index += 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
prompt: TIP_SUPPORT_PROMPTS[index],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
tipCount: 2,
|
onLoadTipMessage: loadTipMessage,
|
||||||
thankYouMessage: "You made my day.",
|
tipMessage: {
|
||||||
|
orderId: "pay_test_001",
|
||||||
|
characterId: "elio",
|
||||||
|
planId: "tip_coffee_usd_9_99",
|
||||||
|
productName: "Golden Reserve",
|
||||||
|
tipCount: 2,
|
||||||
|
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",
|
||||||
tipCount: 2,
|
tipMessage: expect.objectContaining({
|
||||||
thankYouMessage: "You made my day.",
|
tipCount: 2,
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,2 +1,3 @@
|
|||||||
export * from "./catalog";
|
export * from "./catalog";
|
||||||
|
export * from "./gift";
|
||||||
export * from "./order";
|
export * from "./order";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
function initializeCatalogState(
|
||||||
|
context: PaymentState,
|
||||||
|
event: Extract<PaymentEvent, { type: "PaymentInit" }>,
|
||||||
|
): Partial<PaymentState> {
|
||||||
|
const planCatalog = event.catalog ?? context.planCatalog;
|
||||||
|
const giftCharacterId =
|
||||||
|
planCatalog === "tip"
|
||||||
|
? (event.characterId ?? context.giftCharacterId)
|
||||||
|
: null;
|
||||||
|
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 {
|
||||||
|
planCatalog,
|
||||||
|
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
||||||
|
giftCharacterId,
|
||||||
|
requestedGiftCategory:
|
||||||
|
planCatalog === "tip" ? (event.category ?? null) : null,
|
||||||
|
requestedGiftPlanId:
|
||||||
|
planCatalog === "tip" ? (event.planId ?? null) : null,
|
||||||
|
...(catalogChanged || characterChanged || selectionChanged
|
||||||
|
? {
|
||||||
|
...resetOrderState(),
|
||||||
|
plans: [],
|
||||||
|
giftCategories: [],
|
||||||
|
giftProducts: [],
|
||||||
|
selectedGiftCategory: null,
|
||||||
|
selectedPlanId: "",
|
||||||
|
isFirstRecharge: false,
|
||||||
|
autoRenew: planCatalog !== "tip",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const initializeCatalogAction = basePaymentMachineSetup.assign(
|
const initializeCatalogAction = basePaymentMachineSetup.assign(
|
||||||
({ context, event }) => {
|
({ context, event }) =>
|
||||||
if (event.type !== "PaymentInit") return {};
|
event.type === "PaymentInit"
|
||||||
return {
|
? initializeCatalogState(context, event)
|
||||||
planCatalog: event.catalog ?? context.planCatalog,
|
: {},
|
||||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCatalogAction = basePaymentMachineSetup.assign(
|
const refreshCatalogAction = basePaymentMachineSetup.assign(
|
||||||
({ context, event }) => {
|
({ context, event }) =>
|
||||||
if (event.type !== "PaymentInit") return {};
|
event.type === "PaymentInit"
|
||||||
const planCatalog = event.catalog ?? context.planCatalog;
|
? initializeCatalogState(context, event)
|
||||||
const catalogChanged = planCatalog !== context.planCatalog;
|
: {},
|
||||||
return {
|
|
||||||
planCatalog,
|
|
||||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
|
||||||
...(catalogChanged
|
|
||||||
? {
|
|
||||||
plans: [],
|
|
||||||
selectedPlanId: "",
|
|
||||||
isFirstRecharge: false,
|
|
||||||
autoRenew: planCatalog !== "tip",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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: [
|
||||||
target: "loadingCachedPlans",
|
{
|
||||||
actions: "initializeCatalog",
|
guard: "isTipInit",
|
||||||
},
|
target: "loadingGiftProducts",
|
||||||
|
actions: "initializeCatalog",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "loadingCachedPlans",
|
||||||
|
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: [
|
||||||
target: "refreshingPlans",
|
{
|
||||||
actions: "refreshCatalog",
|
guard: "isTipInit",
|
||||||
},
|
target: "loadingGiftProducts",
|
||||||
|
actions: "refreshCatalog",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "refreshingPlans",
|
||||||
|
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" },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/** Route UI reads this projection instead of the Payment machine snapshot. */
|
||||||
* PaymentContext:基于 XState v5 的 React Context Provider
|
|
||||||
*/
|
|
||||||
import type { Dispatch, ReactNode } from "react";
|
import type { Dispatch, ReactNode } from "react";
|
||||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||||
import type { SnapshotFrom } from "xstate";
|
import type { SnapshotFrom } from "xstate";
|
||||||
@@ -15,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"];
|
||||||
@@ -24,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;
|
||||||
@@ -65,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,
|
||||||
@@ -74,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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
/** Public events for one route-scoped Payment actor. */
|
||||||
* Payment 状态机:事件联合
|
|
||||||
*/
|
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import type { PaymentPlanCatalog } from "./payment-state";
|
import type { PaymentPlanCatalog } from "./payment-state";
|
||||||
|
|
||||||
@@ -9,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 }
|
||||||
@@ -21,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" };
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/** Context shared by the default and Tip payment catalogs. */
|
||||||
* Payment 状态机:State 形状 + 初始值
|
|
||||||
*/
|
|
||||||
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";
|
||||||
@@ -12,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;
|
||||||
@@ -21,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;
|
||||||
@@ -31,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",
|
||||||
@@ -40,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,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
PrivateRoomState as MachineContext,
|
PrivateRoomState as MachineContext,
|
||||||
} from "./private-room-machine";
|
} from "./private-room-machine";
|
||||||
|
|
||||||
|
/** Route UI reads this projection instead of the Private Room snapshot. */
|
||||||
export interface PrivateRoomContextState {
|
export interface PrivateRoomContextState {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** Public events for one character-scoped Private Room actor. */
|
||||||
export type PrivateRoomEvent =
|
export type PrivateRoomEvent =
|
||||||
| { type: "PrivateRoomInit" }
|
| { type: "PrivateRoomInit" }
|
||||||
| { type: "PrivateRoomRefresh" }
|
| { type: "PrivateRoomRefresh" }
|
||||||
|
|||||||
Reference in New Issue
Block a user