Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a2accde4 | |||
| 26ab790078 |
@@ -1,403 +0,0 @@
|
||||
# 虚拟礼物商品与付款后文案 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` 是合法结果,应显示本地占位图。
|
||||
- 本接口没有新增数据库迁移,也没有改变现有公开字段命名。
|
||||
@@ -0,0 +1,496 @@
|
||||
# 前端锁消息解锁 API 接入文档
|
||||
|
||||
## 1. 文档状态
|
||||
|
||||
本文档描述 `POST /api/chat/unlock-private` 与 `GET /api/chat/history` 的最新接口协议,覆盖后端已有锁消息和前端临时创建锁消息两种场景。
|
||||
|
||||
当前协议已经实现并通过自动化测试。前端联调前可通过目标环境的 `/openapi.json` 确认 `UnlockPrivateRequest` 已包含 `messageId`、`lockType` 和 `clientLockId`。
|
||||
|
||||
`unlock-private` 响应已收敛为紧凑结构。历史兼容别名不再返回,前端必须使用本文列出的标准 camelCase 字段。
|
||||
|
||||
环境地址:
|
||||
|
||||
| 环境 | API Base URL |
|
||||
| --- | --- |
|
||||
| test 测试环境 | `https://testapi.banlv-ai.com` |
|
||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
||||
|
||||
## 2. 功能目标
|
||||
|
||||
前端可以先展示一条本地锁消息。用户点击解锁时,即使该消息还没有后端 `messageId`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。
|
||||
|
||||
核心规则:
|
||||
|
||||
1. 余额不足时不生成真实语音、图片或私密内容,不扣积分,但会保存锁消息并返回真实 `messageId`。
|
||||
2. 用户充值后,前端使用同一个 `clientLockId` 再次解锁,后端更新原消息,不新增另一条聊天记录。
|
||||
3. 成功扣积分并持久化解锁后,`history` 才返回解锁状态。
|
||||
4. 刷新页面不会自动解锁。未扣积分、未消耗免费私密额度时,`history` 仍返回 `locked=true`。
|
||||
5. 前端只能使用 `unlocked` 或 `lockDetail.locked` 判断锁状态,不能根据 URL 是否为空判断。
|
||||
|
||||
## 3. 身份认证
|
||||
|
||||
两个接口都使用当前聊天用户的登录 Token:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <TOKEN>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
前端不得保存或传递 Supabase service key。
|
||||
|
||||
## 4. 解锁接口
|
||||
|
||||
### 4.1 请求地址
|
||||
|
||||
```http
|
||||
POST <API_BASE_URL>/api/chat/unlock-private
|
||||
```
|
||||
|
||||
生产环境完整地址:
|
||||
|
||||
```http
|
||||
POST https://api.banlv-ai.com/api/chat/unlock-private
|
||||
```
|
||||
|
||||
### 4.2 请求参数
|
||||
|
||||
请求体为 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"messageId": "可选,有就传",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "前端生成的唯一ID,可选但强烈建议"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 是否必填 | 示例 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `messageId` | string | 否 | `"9a43..."` | 后端真实消息 ID。已有消息时传;前端临时锁消息首次解锁时可以不传。 |
|
||||
| `lockType` | string | 临时锁消息必填 | `"voice_message"` | 锁类型。没有有效 `messageId` 时必须传。 |
|
||||
| `clientLockId` | string | 否,强烈建议 | `"fb_voice_3737_001"` | 前端锁卡片唯一 ID,用于充值后重试、刷新找回和避免重复创建。 |
|
||||
|
||||
`lockType` 支持以下值:
|
||||
|
||||
| 标准值 | 兼容别名 | 积分价格 |
|
||||
| --- | --- | ---: |
|
||||
| `voice_message` | `voice`、`audio` | 20 |
|
||||
| `image_paywall` | `image`、`photo`、`picture` | 40 |
|
||||
| `private_message` | `private`、`private_topic`、`private_room` | 10 |
|
||||
|
||||
建议前端只使用标准值。
|
||||
|
||||
### 4.3 `clientLockId` 生成规则
|
||||
|
||||
前端在创建锁卡片时生成一次,之后不得改变:
|
||||
|
||||
```ts
|
||||
const clientLockId = crypto.randomUUID();
|
||||
```
|
||||
|
||||
同一条锁消息在以下场景中必须继续使用同一个值:
|
||||
|
||||
- 第一次点击解锁;
|
||||
- 余额不足后进入充值;
|
||||
- 充值成功后重新点击解锁;
|
||||
- 网络超时后重试;
|
||||
- 前端重新渲染同一条本地锁卡片。
|
||||
|
||||
首次响应拿到后端 `messageId` 后,前端应同时保存 `messageId` 和 `clientLockId`。
|
||||
|
||||
### 4.4 最小请求示例
|
||||
|
||||
解锁已有后端消息:
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"messageId":"<MESSAGE_ID>"}'
|
||||
```
|
||||
|
||||
解锁前端临时语音锁消息:
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"lockType":"voice_message",
|
||||
"clientLockId":"codex_test_voice_001"
|
||||
}'
|
||||
```
|
||||
|
||||
充值后建议把三个字段都传回:
|
||||
|
||||
```json
|
||||
{
|
||||
"messageId": "<FIRST_RESPONSE_MESSAGE_ID>",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "codex_test_voice_001"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 统一响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"success": true,
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
业务解锁失败通常也返回 HTTP 200。前端必须读取 `data.unlocked` 和 `data.reason`,不能只检查 HTTP 状态或顶层 `success`。
|
||||
|
||||
重要字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `unlocked` | boolean | 本次操作后是否已真实解锁。 |
|
||||
| `reason` | string/null | 本次操作结果原因。 |
|
||||
| `messageId` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 |
|
||||
| `clientLockId` | string/null | 后端识别到的前端锁 ID。 |
|
||||
| `lockType` | string/null | 规范化后的锁类型。 |
|
||||
| `content` | string/null | 当前消息文字;私密内容未解锁时可以为 `null`。 |
|
||||
| `type` | string | `text` 或 `voice`。 |
|
||||
| `audioUrl` | string | 锁定语音为空字符串,成功解锁后是完整 URL。 |
|
||||
| `image.url` | string/null | 图片完整 URL;是否展示仍由 `lockDetail.locked` 决定。 |
|
||||
| `creditBalance` | number/null | 操作后的当前积分。 |
|
||||
| `creditsCharged` | number | 本次实际扣除积分;失败或免费额度解锁为 0。 |
|
||||
| `requiredCredits` | number | 当前锁类型需要的积分。 |
|
||||
| `shortfallCredits` | number | 余额不足时还差多少积分。 |
|
||||
| `lockDetail.locked` | boolean | 前端锁卡片的最终展示开关。 |
|
||||
| `lockDetail.showContent` | boolean | 是否允许展示文字内容。 |
|
||||
| `lockDetail.showUpgrade` | boolean | 是否展示充值入口。 |
|
||||
| `lockDetail.reason` | string/null | 锁定原因。 |
|
||||
|
||||
以下重复或与解锁无关的字段已经删除:`message_id`、`messageType`、`reply`、`response`、`audio_url`、`displayMessage`、`localizedMessage`、`paywallTriggered`、`showUpgrade`、`timestamp`、`isGuest`、亲密度字段以及 `blocked` 系列字段。
|
||||
|
||||
## 6. 余额不足
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "fb_voice_37370387172559600_001"
|
||||
}
|
||||
```
|
||||
|
||||
代表性响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"success": true,
|
||||
"data": {
|
||||
"unlocked": false,
|
||||
"reason": "insufficient_credits",
|
||||
"type": "voice",
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_voice_37370387172559600_001",
|
||||
"lockType": "voice_message",
|
||||
"content": "I left a voice message for you. Unlock it to listen.",
|
||||
"audioUrl": "",
|
||||
"creditBalance": 5,
|
||||
"creditsCharged": 0,
|
||||
"requiredCredits": 20,
|
||||
"shortfallCredits": 15,
|
||||
"lockDetail": {
|
||||
"locked": true,
|
||||
"showContent": true,
|
||||
"showUpgrade": true,
|
||||
"reason": "voice_message",
|
||||
"detail": {
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_voice_37370387172559600_001",
|
||||
"lockType": "voice_message",
|
||||
"requiredCredits": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端处理:
|
||||
|
||||
1. 使用返回的 `messageId` 替换本地临时消息 ID。
|
||||
2. 保留原 `clientLockId`。
|
||||
3. 保持锁定状态并打开充值页面。
|
||||
4. 充值完成后再次调用解锁接口。
|
||||
5. 请求期间禁用解锁按钮,避免用户连续点击。
|
||||
|
||||
## 7. 解锁成功
|
||||
|
||||
### 7.1 语音成功
|
||||
|
||||
后端先确认并扣除 20 积分,然后生成真实文字和语音,更新同一条聊天记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"success": true,
|
||||
"data": {
|
||||
"unlocked": true,
|
||||
"reason": "ok",
|
||||
"type": "voice",
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_voice_37370387172559600_001",
|
||||
"lockType": "voice_message",
|
||||
"content": "I made this voice just for you.",
|
||||
"audioUrl": "https://api.banlv-ai.com/audio/<FILE>.mp3",
|
||||
"creditBalance": 10,
|
||||
"creditsCharged": 20,
|
||||
"requiredCredits": 20,
|
||||
"shortfallCredits": 0,
|
||||
"lockDetail": {
|
||||
"locked": false,
|
||||
"showContent": true,
|
||||
"showUpgrade": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端收到后直接用本次响应替换锁卡片,播放地址读取 `data.audioUrl`。
|
||||
|
||||
### 7.2 图片成功
|
||||
|
||||
```json
|
||||
{
|
||||
"unlocked": true,
|
||||
"reason": "ok",
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_image_001",
|
||||
"lockType": "image_paywall",
|
||||
"image": {
|
||||
"type": "elio_schedule",
|
||||
"url": "https://dbapi.banlv-ai.com/storage/v1/object/public/<PATH>"
|
||||
},
|
||||
"creditBalance": 60,
|
||||
"creditsCharged": 40,
|
||||
"requiredCredits": 40,
|
||||
"lockDetail": {
|
||||
"locked": false,
|
||||
"showContent": true,
|
||||
"showUpgrade": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
图片读取 `data.image.url`。后端已有图片锁消息可能在锁定时也返回真实 URL,因为 URL 本身不作为解锁凭证;前端仍必须根据 `lockDetail.locked` 遮挡或禁止查看。
|
||||
|
||||
前端临时创建、尚未确定图片内容的锁消息,在成功扣积分前可能没有图片 URL;成功后后端选择图片并返回 URL。
|
||||
|
||||
### 7.3 私密文本成功
|
||||
|
||||
前端临时私密消息按 10 积分生成并解锁:
|
||||
|
||||
```json
|
||||
{
|
||||
"unlocked": true,
|
||||
"reason": "ok",
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_private_001",
|
||||
"lockType": "private_message",
|
||||
"content": "<REAL_PRIVATE_REPLY>",
|
||||
"creditsCharged": 10,
|
||||
"requiredCredits": 10,
|
||||
"lockDetail": {
|
||||
"locked": false,
|
||||
"showContent": true,
|
||||
"showUpgrade": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
已有后端私密文本仍优先使用每日免费私密额度:登录用户默认每天 2 条,游客默认每天 1 条。成功消耗免费额度后也会持久解锁,但 `creditsCharged=0`。
|
||||
|
||||
## 8. History 接口
|
||||
|
||||
### 8.1 请求
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/chat/history?limit=50&offset=0
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
```bash
|
||||
curl 'https://api.banlv-ai.com/api/chat/history?limit=50&offset=0' \
|
||||
-H 'Authorization: Bearer <TOKEN>'
|
||||
```
|
||||
|
||||
`limit=1&offset=0` 返回最新的一条数据库聊天记录展开后的消息;接口最终按聊天展示顺序返回 `data.messages`。
|
||||
|
||||
### 8.2 锁定消息示例
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "assistant",
|
||||
"type": "voice",
|
||||
"content": "I left a voice message for you. Unlock it to listen.",
|
||||
"id": "<BACKEND_MESSAGE_ID>",
|
||||
"created_at": "2026-07-13T08:00:00+00:00",
|
||||
"audioUrl": null,
|
||||
"image": {
|
||||
"type": null,
|
||||
"url": null
|
||||
},
|
||||
"lockDetail": {
|
||||
"locked": true,
|
||||
"showContent": true,
|
||||
"showUpgrade": true,
|
||||
"reason": "voice_message",
|
||||
"detail": {
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_voice_37370387172559600_001",
|
||||
"lockType": "voice_message",
|
||||
"requiredCredits": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 前端展示规则
|
||||
|
||||
```ts
|
||||
const locked = message.lockDetail?.locked === true;
|
||||
const showText = message.lockDetail?.showContent !== false;
|
||||
|
||||
if (locked) {
|
||||
// 展示锁卡片、遮罩或解锁按钮
|
||||
} else {
|
||||
// 展示真实内容
|
||||
}
|
||||
```
|
||||
|
||||
各类型规则:
|
||||
|
||||
| 状态 | `lockDetail.locked` | URL/内容行为 |
|
||||
| --- | ---: | --- |
|
||||
| 锁定语音 | `true` | `audioUrl=null`,不能播放。 |
|
||||
| 已解锁语音 | `false` | `audioUrl` 是完整可播放 URL。 |
|
||||
| 锁定图片 | `true` | `image.url` 可能有值,也可能为空;必须遮挡。 |
|
||||
| 已解锁图片 | `false` | 使用完整 `image.url` 展示。 |
|
||||
| 锁定私密文本 | `true` | 根据 `showContent` 决定是否显示 `content`。 |
|
||||
| 已解锁私密文本 | `false` | 展示完整 `content`。 |
|
||||
|
||||
## 9. 推荐 TypeScript 类型
|
||||
|
||||
```ts
|
||||
type LockType = "voice_message" | "image_paywall" | "private_message";
|
||||
|
||||
interface UnlockPrivateRequest {
|
||||
messageId?: string;
|
||||
lockType?: LockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
interface LockDetail {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason?: string | null;
|
||||
hint?: string | null;
|
||||
actionLabel?: string | null;
|
||||
detail?: {
|
||||
messageId?: string;
|
||||
clientLockId?: string;
|
||||
lockType?: LockType;
|
||||
requiredCredits?: number;
|
||||
refundedCredits?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface UnlockPrivateData {
|
||||
unlocked: boolean;
|
||||
reason?: string | null;
|
||||
messageId: string;
|
||||
clientLockId?: string | null;
|
||||
lockType?: LockType | null;
|
||||
type: "text" | "voice";
|
||||
content?: string | null;
|
||||
audioUrl: string;
|
||||
image: {
|
||||
type?: string | null;
|
||||
url?: string | null;
|
||||
};
|
||||
creditBalance?: number | null;
|
||||
creditsCharged: number;
|
||||
requiredCredits: number;
|
||||
shortfallCredits: number;
|
||||
lockDetail: LockDetail;
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 异常与前端处理
|
||||
|
||||
| `data.reason` | 含义 | 前端处理 |
|
||||
| --- | --- | --- |
|
||||
| `ok` | 成功扣费并解锁 | 用响应替换锁卡片。 |
|
||||
| `not_locked` | 消息已经不是锁定状态 | 直接按已解锁内容展示,不再扣费。 |
|
||||
| `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 |
|
||||
| `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 |
|
||||
| `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 |
|
||||
| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `lockDetail.hint`。 |
|
||||
| `invalid_lock_type` | `lockType` 不支持 | 记录前端错误并停止请求。 |
|
||||
| `not_found` | 消息不存在且没有有效 `lockType` | 禁用当前解锁按钮或重新同步 history。 |
|
||||
|
||||
认证失败会返回 HTTP 401。请求字段类型不合法可能返回 HTTP 422。
|
||||
|
||||
## 11. 前端完整流程
|
||||
|
||||
```text
|
||||
创建本地锁卡片并生成 clientLockId
|
||||
-> 用户点击解锁
|
||||
-> POST /api/chat/unlock-private
|
||||
-> unlocked=true:替换卡片并展示真实内容
|
||||
-> insufficient_credits:保存 messageId,打开充值
|
||||
-> 充值成功
|
||||
-> 使用相同 messageId + lockType + clientLockId 重试
|
||||
-> unlocked=true:替换卡片
|
||||
-> 页面刷新
|
||||
-> GET /api/chat/history
|
||||
-> 只按 lockDetail.locked 恢复锁定或解锁状态
|
||||
```
|
||||
|
||||
## 12. 测试方法
|
||||
|
||||
必须使用测试账号,不要用真实付费用户验证扣费。
|
||||
|
||||
1. 准备余额低于 20 的测试账号。
|
||||
2. 不传 `messageId`,使用唯一 `clientLockId` 请求 `voice_message`。
|
||||
3. 确认 `unlocked=false`、`creditsCharged=0`,并取得非空 `messageId`。
|
||||
4. 调用 history,确认同一 `messageId` 存在且 `locked=true`、`audioUrl=null`。
|
||||
5. 给测试账号增加到至少 20 积分。
|
||||
6. 使用相同 `messageId`、`lockType`、`clientLockId` 再次请求。
|
||||
7. 确认 `unlocked=true`、`creditsCharged=20`、`audioUrl` 非空。
|
||||
8. 再次调用 history,确认同一消息 `locked=false` 且 `audioUrl` 非空。
|
||||
9. 对图片 40 积分和私密文本 10 积分重复验证。
|
||||
10. 测试结束后记录测试账号积分变化;生成的聊天记录会保留在 history 中,没有自动清理接口。
|
||||
|
||||
后端自动化测试:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest tests\test_private_unlock.py -q
|
||||
```
|
||||
|
||||
当前结果:`20 passed`;私密解锁、图片 paywall、私密相册和私密空间相关回归共 `48 passed`。
|
||||
|
||||
## 13. 兼容性与回滚
|
||||
|
||||
- 已有前端继续只传 `messageId` 的调用方式保持兼容。
|
||||
- 请求参数保持兼容:已有前端继续只传 `messageId` 仍可使用。
|
||||
- 响应字段有收敛:旧别名不再返回,前端需要按第 5、9 节使用标准字段。
|
||||
- 本次功能不需要数据库迁移,通过现有 `chat_messages` 和解锁记录保存状态。
|
||||
- 如果上线后需要回滚,只回滚本次后端路由和 schema 代码即可;已生成的聊天记录可保留,不影响旧 history 读取。
|
||||
- 上线前需将代码提交并推送到目标环境对应分支:`test`、`pro` 或 `main`,再部署对应环境。
|
||||
|
||||
## 14. Needs confirmation
|
||||
|
||||
- `https://api.cozsweet.com` 是否继续作为生产 API 别名,不在本次代码和环境配置中确认;本文统一使用正式生产地址 `https://api.banlv-ai.com`。
|
||||
@@ -0,0 +1,448 @@
|
||||
# 单角色应用迁移为多角色聊天架构
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档定义 CozSweet 从单角色 Elio 迁移到多角色架构时,后端需要完成的接口、数据模型和兼容策略调整。
|
||||
|
||||
迁移目标:
|
||||
|
||||
1. 前端通过统一的 Character Catalog 边界读取角色目录,当前由本地只读目录提供,后端只识别稳定角色 ID。
|
||||
2. 同一用户可以分别与多个角色聊天。
|
||||
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
|
||||
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
|
||||
5. 现有客户端和历史数据继续默认归属于 Elio。
|
||||
|
||||
本文档中的标准角色标识:
|
||||
|
||||
| 类型 | Elio 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 |
|
||||
| `slug` | `elio` | 前端 URL 和外部入口 |
|
||||
|
||||
后端业务接口必须使用 `id`,不能使用可能变化的展示名称。
|
||||
|
||||
环境地址:
|
||||
|
||||
| 环境 | API Base URL |
|
||||
| --- | --- |
|
||||
| test 测试环境 | `https://testapi.banlv-ai.com` |
|
||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
||||
|
||||
## 2. 前端 Character Catalog
|
||||
|
||||
后端角色目录协议尚未确定。过渡阶段由前端本地只读 Catalog 提供角色,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。业务页面只消费 Catalog 边界;后续切换为服务端目录时不改变角色路由和业务组件接口。
|
||||
|
||||
当前角色:
|
||||
|
||||
| `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `character_elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` |
|
||||
| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
|
||||
| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
|
||||
|
||||
当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。
|
||||
|
||||
## 3. 聊天接口调整
|
||||
|
||||
### 3.1 通用规则
|
||||
|
||||
以下接口新增标准字段 `characterId`:
|
||||
|
||||
```text
|
||||
POST /api/chat/send
|
||||
GET /api/chat/history
|
||||
POST /api/chat/unlock-private
|
||||
POST /api/chat/unlock-history
|
||||
```
|
||||
|
||||
`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。
|
||||
|
||||
后端必须使用以下维度定位聊天数据:
|
||||
|
||||
```text
|
||||
正式用户:userId + characterId
|
||||
游客:guest identity/device identity + characterId
|
||||
```
|
||||
|
||||
任何历史查询、消息发送、解锁和配额判断都不能只按用户查找。
|
||||
|
||||
### 3.2 发送消息
|
||||
|
||||
```http
|
||||
POST /api/chat/send
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
现有请求体增加 `characterId`,其他字段保持原协议:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"message": "Hello Elio",
|
||||
"image": "",
|
||||
"imageId": "",
|
||||
"imageThumbUrl": "",
|
||||
"imageMediumUrl": "",
|
||||
"imageOriginalUrl": "",
|
||||
"imageWidth": 0,
|
||||
"imageHeight": 0,
|
||||
"useWebSocket": false
|
||||
}
|
||||
```
|
||||
|
||||
后端处理要求:
|
||||
|
||||
1. 校验角色存在且已启用。
|
||||
2. 使用 `characterId` 选择对应角色 Prompt、模型配置和聊天上下文。
|
||||
3. 只加载该用户与该角色的历史。
|
||||
4. 新消息和生成结果必须写入相同 `characterId`。
|
||||
5. 响应继续沿用现有 `ChatSendResponse`,不额外返回前端不使用的角色字段。
|
||||
|
||||
如果后端启用 WebSocket,连接握手或每条发送帧也必须携带 `characterId`,不能依赖服务端进程内的“当前角色”状态。
|
||||
|
||||
### 3.3 获取聊天历史
|
||||
|
||||
```http
|
||||
GET /api/chat/history?characterId=character_elio&limit=50&offset=0
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `characterId` | string | 是 | 角色业务 ID。 |
|
||||
| `limit` | integer | 否 | 保持现有分页规则。 |
|
||||
| `offset` | integer | 否 | 保持现有分页规则。 |
|
||||
|
||||
响应结构和分页字段保持不变。`messages`、`total` 和 `offset` 必须只统计当前角色。
|
||||
|
||||
禁止在当前角色历史中返回其他角色的消息,即使消息属于同一用户。
|
||||
|
||||
### 3.4 解锁单条消息
|
||||
|
||||
```http
|
||||
POST /api/chat/unlock-private
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
请求体增加 `characterId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"messageId": "message-id",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "client-generated-id"
|
||||
}
|
||||
```
|
||||
|
||||
现有 `messageId`、`lockType` 和 `clientLockId` 规则保持不变。
|
||||
|
||||
后端必须验证:
|
||||
|
||||
- 已存在消息的 `character_id` 与请求 `characterId` 一致。
|
||||
- 临时锁消息不存在 `messageId` 时,新建消息必须写入请求角色。
|
||||
- `clientLockId` 的幂等范围至少包含身份和角色,推荐唯一键:
|
||||
|
||||
```text
|
||||
identity + character_id + client_lock_id
|
||||
```
|
||||
|
||||
跨角色解锁返回 `CHARACTER_MISMATCH`,不能扣积分,也不能修改消息。
|
||||
|
||||
### 3.5 一键解锁历史
|
||||
|
||||
```http
|
||||
POST /api/chat/unlock-history
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
请求体由空请求调整为:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio"
|
||||
}
|
||||
```
|
||||
|
||||
后端只能统计和解锁当前角色的锁消息。`requiredCredits`、解锁数量和返回 messages 都必须按当前角色计算。
|
||||
|
||||
## 4. 角色最新消息接口
|
||||
|
||||
角色列表需要一次性获取各角色的最新消息,避免对每个角色分别请求 history。
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
```http
|
||||
GET /api/chat/previews
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
### 4.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"message": {
|
||||
"id": "message-id",
|
||||
"role": "assistant",
|
||||
"type": "text",
|
||||
"content": "How was your day?",
|
||||
"createdAt": "2026-07-17T10:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"characterId": "character_aria",
|
||||
"message": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `items` 按前端本地角色目录顺序返回当前可用角色。
|
||||
- `message` 使用现有 ChatMessage wire 结构;没有历史时返回 `null`。
|
||||
- 锁消息继续遵循现有内容隐藏规则,不能通过预览接口泄露 URL 或锁定内容。
|
||||
- 查询应批量完成,避免后端内部出现按角色逐条查询的 N+1 问题。
|
||||
|
||||
## 5. 私密空间接口调整
|
||||
|
||||
### 5.1 获取相册
|
||||
|
||||
标准请求改为:
|
||||
|
||||
```http
|
||||
GET /api/private-room/albums?characterId=character_elio&limit=20
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
兼容期继续接受旧参数:
|
||||
|
||||
```http
|
||||
GET /api/private-room/albums?character=elio&limit=20
|
||||
```
|
||||
|
||||
参数优先级:
|
||||
|
||||
1. 存在 `characterId` 时使用标准 ID。
|
||||
2. 兼容期仅存在 `character` 时,将旧 slug 解析为角色 ID。
|
||||
3. 两者同时存在但指向不同角色时返回 `CHARACTER_MISMATCH`。
|
||||
4. 两者都缺失时,兼容期默认 Elio。
|
||||
|
||||
响应结构保持不变,不增加前端未使用的角色字段。
|
||||
|
||||
### 5.2 解锁相册
|
||||
|
||||
接口路径保持不变:
|
||||
|
||||
```http
|
||||
POST /api/private-room/albums/{albumId}/unlock
|
||||
```
|
||||
|
||||
`albumId` 已能唯一确定角色,因此请求体不增加 `characterId`。后端必须根据 album 关联的角色进行权限、积分和解锁记录校验。
|
||||
|
||||
## 6. Tip 支付接口调整
|
||||
|
||||
### 6.1 创建订单
|
||||
|
||||
现有接口:
|
||||
|
||||
```http
|
||||
POST /api/payment/create-order
|
||||
```
|
||||
|
||||
请求体增加可选字段 `recipientCharacterId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"planId": "tip_coffee_medium",
|
||||
"payChannel": "stripe",
|
||||
"autoRenew": false,
|
||||
"recipientCharacterId": "character_elio"
|
||||
}
|
||||
```
|
||||
|
||||
后端必须先根据 `planId` 判断订单类型:
|
||||
|
||||
| 订单类型 | `recipientCharacterId` 规则 |
|
||||
| --- | --- |
|
||||
| Tip | 必填,并校验角色存在且启用 |
|
||||
| VIP | 忽略,不保存 |
|
||||
| Top-up | 忽略,不保存 |
|
||||
|
||||
Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归属、统计和后续展示。
|
||||
|
||||
支付套餐、创建订单响应和订单状态响应不增加角色字段。
|
||||
|
||||
## 7. 数据模型调整
|
||||
|
||||
### 7.1 Characters 表
|
||||
|
||||
后台建议至少保存:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | 主键,稳定角色 ID。 |
|
||||
| `slug` | 唯一 URL 标识。 |
|
||||
| `display_name` | 展示名称。 |
|
||||
| `avatar_url` | 头像地址。 |
|
||||
| `enabled` | 是否对用户启用,仅后台使用。 |
|
||||
| `sort_order` | 运营排序,仅后台使用。 |
|
||||
| `created_at` | 创建时间。 |
|
||||
| `updated_at` | 更新时间。 |
|
||||
|
||||
`enabled` 和 `sort_order` 是后端管理字段,不返回前端。
|
||||
|
||||
### 7.2 业务表
|
||||
|
||||
以下数据增加 `character_id` 外键:
|
||||
|
||||
- conversations 或等价会话表;
|
||||
- chat messages;
|
||||
- 锁消息、解锁记录和 `clientLockId` 幂等记录;
|
||||
- private albums 及相册解锁记录;
|
||||
- Tip 订单或 Tip 业务记录。
|
||||
|
||||
推荐索引:
|
||||
|
||||
```text
|
||||
(user_id, character_id, created_at)
|
||||
(guest_id, character_id, created_at)
|
||||
(character_id, enabled)
|
||||
(identity, character_id, client_lock_id) UNIQUE
|
||||
```
|
||||
|
||||
如果当前系统没有 conversations 表,可以继续使用 messages,但所有历史查询必须显式包含 `character_id`。
|
||||
|
||||
## 8. 历史数据迁移
|
||||
|
||||
上线新接口前执行:
|
||||
|
||||
1. 创建 Elio 角色,固定 `id=character_elio`、`slug=elio`。
|
||||
2. 为相关业务表增加可空 `character_id`。
|
||||
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio`。
|
||||
4. 检查不存在空角色或无法关联的数据。
|
||||
5. 创建联合索引和外键。
|
||||
6. 将必要业务表的 `character_id` 调整为非空。
|
||||
|
||||
回填前后需要核对每张表的记录数量,迁移不能删除历史聊天或解锁状态。
|
||||
|
||||
## 9. 兼容发布策略
|
||||
|
||||
### 第一阶段:后端兼容
|
||||
|
||||
- 上线角色 ID 配置和新字段。
|
||||
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。
|
||||
- Private Room 缺少角色时暂时默认 Elio。
|
||||
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
|
||||
- 记录缺少角色参数的调用次数和客户端版本。
|
||||
|
||||
### 第二阶段:前端迁移
|
||||
|
||||
- 新前端所有请求显式发送角色 ID。
|
||||
- 动态路由和支付回跳携带角色上下文。
|
||||
- 观察错误率、跨角色校验和旧请求占比。
|
||||
|
||||
### 第三阶段:收紧协议
|
||||
|
||||
- Chat 接口缺少 `characterId` 时返回 `CHARACTER_REQUIRED`。
|
||||
- Tip 订单缺少 recipient 时返回 `CHARACTER_REQUIRED`。
|
||||
- 删除 Private Room 旧 `character` 参数。
|
||||
- 删除默认 Elio 的接口兼容逻辑。
|
||||
|
||||
由于 PWA 和浏览器可能长期缓存旧前端,第三阶段只能在旧请求流量降至可接受水平后执行。
|
||||
|
||||
只有“完全缺少角色字段”的旧请求可以在兼容期默认 Elio。显式传入不存在、禁用或不匹配角色时不得回退 Elio。
|
||||
|
||||
## 10. 错误响应
|
||||
|
||||
统一失败 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Character is not available",
|
||||
"error": "CHARACTER_DISABLED"
|
||||
}
|
||||
```
|
||||
|
||||
新增错误:
|
||||
|
||||
| HTTP 状态 | error | 场景 |
|
||||
| ---: | --- | --- |
|
||||
| `400` | `CHARACTER_REQUIRED` | 收紧协议后缺少角色字段。 |
|
||||
| `404` | `CHARACTER_NOT_FOUND` | `id` 或兼容 slug 不存在。 |
|
||||
| `403` | `CHARACTER_DISABLED` | 角色存在但未启用。 |
|
||||
| `409` | `CHARACTER_MISMATCH` | 消息、相册或重复参数属于不同角色。 |
|
||||
|
||||
角色错误不能扣积分、创建消息、创建订单或修改任何解锁状态。
|
||||
|
||||
## 11. 安全和一致性要求
|
||||
|
||||
- 角色 ID 必须由后端查询验证,不能信任客户端展示信息。
|
||||
- 用户只能读取自己的某个角色会话,不能通过替换 characterId 读取其他用户数据。
|
||||
- messageId、albumId 和 clientLockId 必须同时校验身份与角色归属。
|
||||
- 角色禁用后不能继续发送、解锁、创建相册订单或 Tip 订单。
|
||||
- 角色禁用不删除历史数据,恢复启用后历史仍可读取。
|
||||
- 日志可以记录 `characterId`,但不能记录聊天正文、Token 或私密媒体 URL。
|
||||
- Analytics 和业务统计统一使用角色 ID,不使用 displayName。
|
||||
|
||||
## 12. 联调验收
|
||||
|
||||
后端完成后至少验证:
|
||||
|
||||
1. 前端本地目录中的每个角色 ID 均可调用 Chat、Private Room 和 Tip 业务接口。
|
||||
2. Login Token 和 Guest Token 均可按角色 ID 聊天。
|
||||
3. 同一用户在 Elio 和其他角色中的历史完全隔离。
|
||||
4. 发送消息、历史分页、单条解锁和历史解锁都限定当前角色。
|
||||
5. 使用 Elio 的 messageId 配合其他角色 ID 解锁时返回 `CHARACTER_MISMATCH`,且不扣积分。
|
||||
6. Private Room 的标准 characterId 与旧 character slug 在兼容期结果一致。
|
||||
7. Tip 订单正确保存 `recipient_character_id`,VIP 和 Top-up 不保存。
|
||||
8. 角色预览接口一次返回各启用角色的最新消息,没有历史时返回 `null`。
|
||||
9. 后端禁用角色后,直接访问业务接口返回 `CHARACTER_DISABLED`;前端发布时同步移除本地记录。
|
||||
10. 缺少角色字段的旧请求在兼容期仍进入 Elio。
|
||||
11. 历史数据回填前后记录数量、积分和解锁状态一致。
|
||||
|
||||
## 13. OpenAPI 参考
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
/api/chat/history:
|
||||
get:
|
||||
parameters:
|
||||
- in: query
|
||||
name: characterId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
/api/chat/unlock-history:
|
||||
post:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [characterId]
|
||||
properties:
|
||||
characterId:
|
||||
type: string
|
||||
```
|
||||
@@ -1,95 +1,28 @@
|
||||
# CozSweet 多角色聊天权威协议
|
||||
# CozSweet 多角色聊天前端对接
|
||||
|
||||
## 1. 状态与范围
|
||||
## 3. Character IDs
|
||||
|
||||
本文是前端仓库中多角色目录、聊天、解锁、缓存隔离及关联业务的唯一人工维护协议。旧的单角色迁移方案和独立锁媒体协议已删除,不再作为实现或联调依据。
|
||||
|
||||
协议描述当前前端已经执行的行为,不描述尚未落地的后端迁移步骤。字段和路径由下列机器可验证入口约束:
|
||||
|
||||
| 边界 | 实现位置 |
|
||||
| --- | --- |
|
||||
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||
| 请求与响应字段 | `src/data/schemas/character`、`src/data/schemas/chat` |
|
||||
| 角色 ID、slug 与本地资源 | `src/data/constants/character.ts` |
|
||||
| Chat UI 消息身份 | `src/stores/chat/ui-message.ts` |
|
||||
| 本地缓存身份 | `src/data/repositories/chat_cache_identity.ts`、`src/lib/chat/chat_cache_keys.ts` |
|
||||
|
||||
如本文与上述实现发生差异,修改代码时必须在同一变更中更新本文,不能新增第二份并行协议。
|
||||
|
||||
## 2. 角色目录与路由
|
||||
|
||||
### 2.1 角色身份
|
||||
|
||||
`id` 是 API、状态机、缓存和数据归属使用的稳定业务身份;`slug` 只用于 URL。
|
||||
|
||||
| `id` | `slug` | 展示名称 |
|
||||
| --- | --- | --- |
|
||||
| `elio` | `elio` | Elio Silvestri |
|
||||
| `maya-tan` | `maya` | Maya Tan |
|
||||
| `nayeli-cervantes` | `nayeli` | Nayeli Cervantes |
|
||||
|
||||
不得向业务 API 发送 slug、展示名称、`@handle` 或旧 ID。旧 ID `character_elio`、`character_maya`、`character_nayeli` 仅用于本地持久化数据迁移。
|
||||
|
||||
### 2.2 角色目录接口
|
||||
前端先调用:
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/characters?capability=chat
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
标准响应数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "maya-tan",
|
||||
"displayName": "Maya Tan",
|
||||
"isActive": true,
|
||||
"capabilities": {
|
||||
"chat": true,
|
||||
"privateContent": true
|
||||
},
|
||||
"sortOrder": 20
|
||||
}
|
||||
],
|
||||
"defaultCharacterId": "elio"
|
||||
}
|
||||
```
|
||||
|
||||
前端只接纳同时满足以下条件的角色:
|
||||
|
||||
1. `id` 存在于本地资源目录;
|
||||
2. `isActive=true`;
|
||||
3. `capabilities.chat=true`。
|
||||
|
||||
服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateRoom` 只有在本地能力和服务端 `privateContent` 同时开启时可用。
|
||||
|
||||
远端目录返回前,生产环境只使用 Elio 作为临时目录;非生产环境可使用完整本地目录。远端目录加载成功后,以合并结果为准。
|
||||
|
||||
### 2.3 页面路由
|
||||
聊天业务统一使用响应里的 `items[].id`。当前规范 ID 是:
|
||||
|
||||
```text
|
||||
/characters/{slug}/splash
|
||||
/characters/{slug}/chat
|
||||
/characters/{slug}/private-room
|
||||
/characters/{slug}/tip
|
||||
elio
|
||||
maya-tan
|
||||
nayeli-cervantes
|
||||
```
|
||||
|
||||
当前 Splash 是角色详情入口,不是全角色列表页。以下旧地址保留为默认角色重定向,并原样保留查询参数:
|
||||
只允许传递角色目录响应中的 `items[].id`,不要传展示名或 `@handle`。
|
||||
|
||||
```text
|
||||
/splash -> /characters/elio/splash
|
||||
/chat -> /characters/elio/chat
|
||||
/private-room -> /characters/elio/private-room
|
||||
/tip -> /characters/elio/tip
|
||||
```
|
||||
角色响应中的 `capabilities` 是后端权威开关。`chat=false` 时不得开放输入框;直接调用聊天接口会得到 `CHARACTER_DISABLED`,不会返回 Elio 历史。
|
||||
|
||||
Chat Provider 以 `characterId` 为边界,路由角色变化时创建新的 Actor。Private Room 的角色边界由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
|
||||
## 4. Send Message
|
||||
|
||||
## 3. Chat HTTP 协议
|
||||
|
||||
### 3.1 发送消息
|
||||
### Request URL
|
||||
|
||||
```http
|
||||
POST <API_BASE_URL>/api/chat/send
|
||||
@@ -97,56 +30,73 @@ Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "maya-tan",
|
||||
"message": "Hello Maya",
|
||||
"useWebSocket": false
|
||||
}
|
||||
### Parameters
|
||||
|
||||
| Field | Type | Required | Example | Meaning |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `characterId` | string | 新前端必传 | `maya-tan` | 当前聊天角色 ID |
|
||||
| `message` | string | 与图片至少一项有值 | `Hello Maya` | 用户文本,最多 4000 字符 |
|
||||
| `imageId` | string | 否 | `abc123` | 上传图片 ID |
|
||||
| `imageThumbUrl` | string | 否 | `/images/...` | 缩略图 |
|
||||
| `imageMediumUrl` | string | 否 | `/images/...` | 模型识图使用 |
|
||||
| `imageOriginalUrl` | string | 否 | `/images/...` | 原图 |
|
||||
| `imageWidth` | integer | 否 | `1080` | 图片宽 |
|
||||
| `imageHeight` | integer | 否 | `1440` | 图片高 |
|
||||
| `useWebSocket` | boolean | 否 | `false` | 是否同时通过已连接 WS 推送 |
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://proapi.banlv-ai.com/api/chat/send' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"characterId":"maya-tan","message":"Hello Maya","useWebSocket":false}'
|
||||
```
|
||||
|
||||
请求规则:
|
||||
响应沿用现有发送结构。前端仍读取 `data.reply`、`data.messageId`、`data.audioUrl`、`data.image` 和 `data.lockDetail`,无需从响应推断角色。
|
||||
|
||||
- `characterId` 必填;
|
||||
- `message` 最大 4000 字符;
|
||||
- `message` 与 `imageId`、`imageThumbUrl`、`imageMediumUrl`、`imageOriginalUrl` 至少一项有效;
|
||||
- 图片请求可附带 `imageWidth`、`imageHeight`;
|
||||
- `useWebSocket` 缺失时按 `false` 处理。
|
||||
|
||||
前端使用的响应字段为:
|
||||
|
||||
```text
|
||||
reply, audioUrl, messageId, isGuest, timestamp, image, lockDetail,
|
||||
canSendMessage, creditBalance, creditsCharged, requiredCredits,
|
||||
shortfallCredits
|
||||
```
|
||||
|
||||
`messageId` 是后端消息身份,不是 React key。锁状态只读取 `lockDetail.locked` 和 `lockDetail.reason`。
|
||||
|
||||
### 3.2 获取历史
|
||||
## 5. Chat History
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/chat/history?characterId=maya-tan&limit=50&offset=0
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
| Query | 必填 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| `characterId` | 是 | 角色业务 ID |
|
||||
| `limit` | 否 | 前端默认 50 |
|
||||
| `offset` | 否 | 前端默认 0 |
|
||||
| Query | Type | Required | Rule |
|
||||
| --- | --- | --- | --- |
|
||||
| `characterId` | string | 新前端必传 | 只返回该角色数据 |
|
||||
| `limit` | integer | 否 | `1-200`,默认 `50` |
|
||||
| `offset` | integer | 否 | 最小 `0` |
|
||||
|
||||
响应数据包含 `messages`、`total`、`limit`、`offset`,并可包含当前私密额度快照。消息字段:
|
||||
`messages` 和 `total` 都只统计指定角色。切换角色时必须重新请求 history,不能复用上一个角色的本地数组。
|
||||
|
||||
```text
|
||||
role, type, content, id, createdAt/created_at, audioUrl, image, lockDetail
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"success": true,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"type": "text",
|
||||
"content": "Hello Maya",
|
||||
"id": "<MESSAGE_ID>",
|
||||
"created_at": "2026-07-17T09:00:00Z",
|
||||
"audioUrl": null,
|
||||
"image": {"type": null, "url": null},
|
||||
"lockDetail": {"locked": false, "showContent": true, "showUpgrade": false}
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"isVip": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端兼容 `createdAt` 和 `created_at` 输入,解析后统一为 `createdAt`。缺失的可空载荷会由 Schema 归一化,业务组件不直接读取原始 envelope。
|
||||
## 6. Latest Previews
|
||||
|
||||
本地快照可先进入可渲染状态;网络历史随后覆盖缓存并更新 UI。网络同步期间新增的乐观消息按 `displayId` 保留。向上拉取更早页面时也按 `displayId` 去重。
|
||||
|
||||
### 3.3 最新消息预览
|
||||
角色会话列表可一次请求所有可聊天角色的最后一条消息:
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/chat/previews
|
||||
@@ -155,29 +105,20 @@ Authorization: Bearer <TOKEN>
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"characterId": "elio",
|
||||
"message": {
|
||||
"id": "<MESSAGE_ID>",
|
||||
"role": "assistant",
|
||||
"type": "text",
|
||||
"content": "How was your day?"
|
||||
}
|
||||
},
|
||||
{
|
||||
"characterId": "maya-tan",
|
||||
"message": null
|
||||
}
|
||||
]
|
||||
"code": 200,
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{"characterId": "elio", "message": {"id": "...", "role": "assistant", "type": "text", "content": "..."}},
|
||||
{"characterId": "maya-tan", "message": null}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
接口可用时,Splash 以批量结果更新各角色预览;接口失败时,当前角色可回退到 `history?limit=1&offset=0`。预览缓存仍使用角色会话身份隔离。
|
||||
锁定私密文本不会泄露内容,锁定语音不会返回 `audioUrl`(文字仍正常返回)。图片沿用现有协议,URL 可以存在;前端必须以 `lockDetail.locked=true` 覆盖展示,不能把“URL 非空”当作已解锁。
|
||||
|
||||
## 4. 解锁协议
|
||||
|
||||
### 4.1 解锁单条消息
|
||||
## 7. Unlock One Message
|
||||
|
||||
```http
|
||||
POST <API_BASE_URL>/api/chat/unlock-private
|
||||
@@ -185,144 +126,44 @@ Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
已有后端消息:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "maya-tan",
|
||||
"messageId": "<MESSAGE_ID>"
|
||||
}
|
||||
```
|
||||
|
||||
前端 Promotion 锁卡片:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "maya-tan",
|
||||
"messageId": "<MESSAGE_ID>",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "<STABLE_CLIENT_LOCK_ID>"
|
||||
"clientLockId": "maya-card-001"
|
||||
}
|
||||
```
|
||||
|
||||
请求必须包含 `characterId`,并至少包含 `messageId` 或 `lockType`。标准 `lockType` 为:
|
||||
`messageId` 存在时,后端会核对消息角色。拿 Elio 的 `messageId` 配 Maya 的 `characterId` 会返回 HTTP `409 / CHARACTER_MISMATCH`,且不会扣积分。
|
||||
|
||||
```text
|
||||
voice_message
|
||||
image_paywall
|
||||
private_message
|
||||
```
|
||||
前端临时伪造锁卡片没有 `messageId` 时,仍传 `characterId + lockType + clientLockId`。`clientLockId` 的幂等查找范围已包含账号和角色,同一个 ID 可以在不同角色下分别使用。
|
||||
|
||||
前端解析的响应字段:
|
||||
|
||||
```text
|
||||
unlocked, content, messageId, clientLockId, lockType, audioUrl, image,
|
||||
reason, creditBalance, creditsCharged, requiredCredits, shortfallCredits
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
1. `unlocked=true` 时更新内容、音频、图片和锁状态;
|
||||
2. `unlocked=false` 时保持锁定,并根据 `reason` 决定是否打开支付流程;
|
||||
3. `reason=not_found` 不打开支付流程;
|
||||
4. 响应 `messageId` 只写入 `remoteId`,不得替换 `displayId`;
|
||||
5. Promotion 重试继续使用同一个 `clientLockId`;
|
||||
6. 跨支付、登录和图片 Overlay 恢复时同时保存显示身份与后端身份。
|
||||
|
||||
### 4.2 解锁当前角色历史
|
||||
## 8. Unlock Current Character History
|
||||
|
||||
```http
|
||||
POST <API_BASE_URL>/api/chat/unlock-history
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
```json
|
||||
{"characterId":"maya-tan"}
|
||||
```
|
||||
|
||||
请求和返回消息都限定当前角色。前端完成解锁后重新读取该角色历史,不复用其他角色 Actor 的消息。
|
||||
费用、锁消息数量、成功解锁数量及 `messageIds` 都只计算当前角色。钱包余额仍是账号全局余额。
|
||||
|
||||
### 4.3 角色错误
|
||||
## 12. Tip Attribution
|
||||
|
||||
前端识别以下后端错误码:
|
||||
只有 Tip 订单使用角色归属:
|
||||
|
||||
| 错误码 | 前端行为 |
|
||||
| --- | --- |
|
||||
| `CHARACTER_DISABLED` | 禁止继续发送并刷新角色目录 |
|
||||
| `CHARACTER_NOT_FOUND` | 清理待恢复导航、刷新目录并返回默认角色 |
|
||||
| `CHARACTER_MISMATCH` | 不进入支付,刷新当前角色历史 |
|
||||
| `CHARACTER_STATE_UNAVAILABLE` | 保留会话并提示稍后重试 |
|
||||
|
||||
任何角色错误都不得在前端回退为另一个角色的历史。
|
||||
|
||||
## 5. UI 消息身份协议
|
||||
|
||||
网络字段继续使用 `messageId`;前端 UI 严格区分三种身份:
|
||||
|
||||
| 字段 | 用途 | 是否稳定 |
|
||||
| --- | --- | --- |
|
||||
| `displayId` | React key、DOM 定位、Overlay URL、解锁目标 | 消息生命周期内必须稳定 |
|
||||
| `remoteId` | 后端 `messageId`、媒体缓存、解锁 API | 后端返回后可补充 |
|
||||
| `clientId` | 乐观发送消息或无后端 ID 的回复 | 创建时生成 |
|
||||
|
||||
标准 `displayId`:
|
||||
|
||||
```text
|
||||
历史消息 server:{encodedRemoteId}:{assistant|user}
|
||||
同角色重复 ID server:{encodedRemoteId}:{role}:{occurrence}
|
||||
无后端 ID 历史 legacy:{stableHash}
|
||||
乐观文本 client:message:{uuid}
|
||||
乐观图片 client:image:{uuid}
|
||||
无 ID 回复 client:reply:{uuid}
|
||||
错误提示 client:error:{uuid}
|
||||
空会话问候语 greeting:{characterId}
|
||||
Promotion promotion:{clientLockId}
|
||||
```json
|
||||
{
|
||||
"planId": "tip_coffee_usd_4_99",
|
||||
"payChannel": "stripe",
|
||||
"autoRenew": false,
|
||||
"recipientCharacterId": "maya-tan"
|
||||
}
|
||||
```
|
||||
|
||||
同一个后端 ID 可能同时出现在 user 和 assistant 记录中,因此不得直接使用 `remoteId` 作为 React key。解锁前后 `displayId` 不变;图片 Overlay 始终写入 `displayId`,但读取时兼容旧的 `remoteId` URL。
|
||||
|
||||
## 6. 本地缓存与会话隔离
|
||||
|
||||
缓存 owner 规则:
|
||||
|
||||
```text
|
||||
正式用户或已有用户 ID 的会话 user:{userId}
|
||||
没有用户 ID 的 Guest 会话 device:{deviceId}
|
||||
```
|
||||
|
||||
正式账号缺少持久化用户 ID 时跳过 Chat 缓存,不得回退到设备级或匿名缓存。
|
||||
|
||||
消息和媒体共同使用会话键:
|
||||
|
||||
```text
|
||||
conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)}
|
||||
```
|
||||
|
||||
媒体键在该会话下继续包含后端消息 ID 和媒体类型:
|
||||
|
||||
```text
|
||||
{conversationKey}:{remoteId}:{image|audio}
|
||||
```
|
||||
|
||||
旧的无角色 owner key 迁移到 Elio;包含旧角色 ID 的 key 迁移到当前后端 ID。迁移是幂等的,不能覆盖已经存在的当前命名空间数据。
|
||||
|
||||
切换用户、Guest/正式账号或角色时,不得复用前一个 `conversationKey`。切换角色会销毁旧 Chat Actor;请求取消信号会阻止旧响应写入新角色状态。
|
||||
|
||||
## 7. 关联业务边界
|
||||
|
||||
- Private Room 的相册、解锁、Gallery 和支付回跳由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
|
||||
- Tip 的角色归属、订单轮询和支付回跳由 [Payment 权威协议](./FRONTEND_PAYMENT_API.md) 定义;Chat 只保存解锁所需的原角色回跳地址。
|
||||
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
||||
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
||||
|
||||
## 8. 变更验收
|
||||
|
||||
协议相关变更至少验证:
|
||||
|
||||
1. Character、Chat、Storage、Navigation 和 Payment 的 TypeScript 类型检查;
|
||||
2. `src/data/services/api/__tests__/multi_character_api.test.ts`;
|
||||
3. `src/stores/chat/__tests__`;
|
||||
4. `src/data/storage/chat/__tests__` 与 `src/data/storage/navigation/__tests__`;
|
||||
5. `src/app/chat/__tests__` 及 Chat 组件测试;
|
||||
6. 同一用户的不同角色、Guest 与正式账号、账号切换后的历史和媒体均不串联;
|
||||
7. 快速切换角色时,旧请求不能覆盖新 Actor;
|
||||
8. 动态 URL、旧地址重定向、登录和支付回跳恢复正确角色;
|
||||
9. 解锁后 `displayId` 不变,`remoteId` 可从响应补齐。
|
||||
Tip 订单必须携带 `recipientCharacterId`。VIP 和积分充值会忽略该字段。
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
# 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`。`tipCount` 表示当前付款身份对同一商品的累计成功次数。
|
||||
|
||||
Tip 成功页在文案加载期间立即确认支付成功。`tipCount=1` 时展示固定首次咖啡文案并忽略后端 `message`;大于 1 时按纯文本直接展示 `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 解锁协调结果正确。
|
||||
@@ -1,235 +0,0 @@
|
||||
# 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 的相册与解锁状态不再可见。
|
||||
@@ -10,11 +10,5 @@ export const tipPaymentPlansResponse = { plans: [
|
||||
] };
|
||||
export const paymentOrderId = "order_e2e_vip_monthly";
|
||||
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",
|
||||
creditsAdded: 0,
|
||||
};
|
||||
export const paidPaymentOrderStatusResponse = { orderId: paymentOrderId, status: "paid", orderType: "vip_monthly", planId: "vip_monthly" };
|
||||
export const vipStatusResponse = { isVip: false, expiresAt: null };
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 MiB After Width: | Height: | Size: 351 KiB |
@@ -62,28 +62,6 @@ describe("PaymentMethodSelector", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders compact controls without the caption", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentMethodSelector
|
||||
config={{
|
||||
canChoosePaymentMethod: true,
|
||||
initialPayChannel: "ezpay",
|
||||
showPaymentMethodSelector: true,
|
||||
}}
|
||||
value="ezpay"
|
||||
density="compact"
|
||||
caption="This caption is hidden"
|
||||
analyticsKey="tip.payment_method"
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-density="compact"');
|
||||
expect(html).toContain("Payment Method");
|
||||
expect(html).not.toContain("This caption is hidden");
|
||||
expect(html).toContain("min-h-13");
|
||||
});
|
||||
|
||||
it("does not render when payment method selection is unavailable", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentMethodSelector
|
||||
|
||||
@@ -24,7 +24,6 @@ const PAYMENT_METHODS: readonly {
|
||||
export interface PaymentMethodSelectorProps {
|
||||
config: PaymentMethodConfig;
|
||||
value: PayChannel;
|
||||
density?: "default" | "compact";
|
||||
disabled?: boolean;
|
||||
caption?: string;
|
||||
className?: string;
|
||||
@@ -35,7 +34,6 @@ export interface PaymentMethodSelectorProps {
|
||||
export function PaymentMethodSelector({
|
||||
config,
|
||||
value,
|
||||
density = "default",
|
||||
disabled = false,
|
||||
caption = "Stripe by default",
|
||||
className,
|
||||
@@ -43,7 +41,6 @@ export function PaymentMethodSelector({
|
||||
onChange,
|
||||
}: PaymentMethodSelectorProps) {
|
||||
if (!config.showPaymentMethodSelector) return null;
|
||||
const isCompact = density === "compact";
|
||||
|
||||
const paymentMethods =
|
||||
config.initialPayChannel === "ezpay"
|
||||
@@ -54,31 +51,26 @@ export function PaymentMethodSelector({
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`flex flex-col ${isCompact ? "gap-2" : "gap-[clamp(8px,1.852vw,10px)]"} ${className ?? ""}`.trim()}
|
||||
data-density={density}
|
||||
className={`flex flex-col gap-[clamp(8px,1.852vw,10px)] ${className ?? ""}`.trim()}
|
||||
aria-labelledby="payment-method-title"
|
||||
>
|
||||
<div
|
||||
className={`flex items-baseline justify-between gap-sm ${isCompact ? "px-0.5" : "px-xs"}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-sm px-xs">
|
||||
<h2
|
||||
id="payment-method-title"
|
||||
className={`m-0 font-bold leading-[1.2] text-auth-text-primary ${isCompact ? "text-base" : "text-(length:--responsive-card-title,var(--font-size-lg))"}`}
|
||||
className="m-0 text-(length:--responsive-card-title,var(--font-size-lg)) font-bold leading-[1.2] text-auth-text-primary"
|
||||
style={{ fontFamily: "var(--font-athelas), var(--font-system)" }}
|
||||
>
|
||||
Payment Method
|
||||
</h2>
|
||||
{!isCompact ? (
|
||||
<span className="whitespace-nowrap text-(length:--responsive-caption,var(--font-size-sm)) leading-[1.2] text-text-secondary">
|
||||
{caption}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="whitespace-nowrap text-(length:--responsive-caption,var(--font-size-sm)) leading-[1.2] text-text-secondary">
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
{paymentMethods.map((method) => {
|
||||
const selected = method.channel === value;
|
||||
const classes = [
|
||||
`flex cursor-pointer items-center justify-center text-center font-[inherit] text-[#3c3b3b] transition-[border-color,box-shadow,transform] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent active:enabled:scale-98 disabled:cursor-not-allowed disabled:opacity-72 ${isCompact ? "min-h-13 rounded-[18px] px-3 py-2" : "min-h-[clamp(54px,11.481vw,62px)] rounded-(--responsive-card-radius-sm,18px) px-[clamp(12px,2.593vw,14px)] py-[clamp(9px,1.852vw,10px)]"}`,
|
||||
"flex min-h-[clamp(54px,11.481vw,62px)] cursor-pointer items-center justify-center rounded-(--responsive-card-radius-sm,18px) px-[clamp(12px,2.593vw,14px)] py-[clamp(9px,1.852vw,10px)] text-center font-[inherit] text-[#3c3b3b] transition-[border-color,box-shadow,transform] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent active:enabled:scale-98 disabled:cursor-not-allowed disabled:opacity-72",
|
||||
selected
|
||||
? "border-2 border-[#f657a0] bg-[#fff4f9] shadow-[0_8px_18px_rgba(217,47,127,0.24),0_0_0_3px_rgba(217,47,127,0.18)] -translate-y-0.5"
|
||||
: "border border-[rgba(246,87,160,0.18)] bg-white shadow-[0_5px_7px_0_rgba(247,89,168,0.08)]",
|
||||
@@ -105,7 +97,7 @@ export function PaymentMethodSelector({
|
||||
aria-hidden="true"
|
||||
width={122}
|
||||
height={29}
|
||||
className={`block object-contain ${isCompact ? "max-h-8 w-[min(100%,92px)]" : "max-h-8.5 w-[min(100%,92px)]"}`}
|
||||
className="block max-h-8.5 w-[min(100%,92px)] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-(length:--responsive-body,16px) font-bold leading-[1.1] text-[#1e1e1e]">
|
||||
|
||||
@@ -38,16 +38,4 @@ describe("shouldInspectPendingPaymentOrder", () => {
|
||||
}),
|
||||
).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,6 +12,7 @@ import { behaviorAnalytics } from "@/lib/analytics";
|
||||
import type {
|
||||
PendingPaymentReturnTo,
|
||||
PendingPaymentSubscriptionType,
|
||||
PendingPaymentTipCoffeeType,
|
||||
} from "@/lib/payment/pending_payment_order";
|
||||
import type {
|
||||
PaymentContextState,
|
||||
@@ -38,8 +39,7 @@ export interface UsePaymentLaunchFlowInput {
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
characterSlug?: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
giftCategory?: string | null;
|
||||
giftPlanId?: string | null;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
}
|
||||
|
||||
export interface PaymentLaunchFlow {
|
||||
@@ -110,8 +110,7 @@ export function usePaymentLaunchFlow({
|
||||
returnTo,
|
||||
characterSlug,
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
tipCoffeeType,
|
||||
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
||||
const launchedNonceRef = useRef(0);
|
||||
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
||||
@@ -154,8 +153,7 @@ export function usePaymentLaunchFlow({
|
||||
orderId: payment.currentOrderId,
|
||||
paymentUrl,
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
...(characterSlug ? { characterSlug } : {}),
|
||||
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
|
||||
@@ -199,8 +197,7 @@ export function usePaymentLaunchFlow({
|
||||
paymentDispatch,
|
||||
returnTo,
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
tipCoffeeType,
|
||||
]);
|
||||
|
||||
const shouldShowStripeDialog =
|
||||
@@ -241,8 +238,7 @@ export function usePaymentLaunchFlow({
|
||||
orderId: payment.currentOrderId,
|
||||
paymentUrl: ezpayPaymentUrl,
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
...(characterSlug ? { characterSlug } : {}),
|
||||
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
|
||||
|
||||
@@ -19,9 +19,6 @@ export interface UsePaymentRouteFlowInput {
|
||||
initialPayChannel: PayChannel;
|
||||
paymentType: PendingPaymentSubscriptionType;
|
||||
shouldResumePendingOrder: boolean;
|
||||
characterId?: string;
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
}
|
||||
|
||||
export interface PaymentRouteFlow {
|
||||
@@ -38,19 +35,10 @@ export function usePaymentRouteFlow({
|
||||
initialPayChannel,
|
||||
paymentType,
|
||||
shouldResumePendingOrder,
|
||||
characterId,
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const initializedCatalogKeyRef = useRef<string | null>(null);
|
||||
const catalogKey = [
|
||||
catalog,
|
||||
characterId ?? "",
|
||||
initialCategory ?? "",
|
||||
initialPlanId ?? "",
|
||||
].join(":");
|
||||
const initialPayChannelAppliedRef = useRef(false);
|
||||
|
||||
usePendingPaymentOrderLifecycle({
|
||||
payment,
|
||||
@@ -60,23 +48,34 @@ export function usePaymentRouteFlow({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedCatalogKeyRef.current === catalogKey) return;
|
||||
initializedCatalogKeyRef.current = catalogKey;
|
||||
if (payment.status === "idle") {
|
||||
initialPayChannelAppliedRef.current = true;
|
||||
paymentDispatch({
|
||||
type: "PaymentInit",
|
||||
catalog,
|
||||
payChannel: initialPayChannel,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
initialPayChannelAppliedRef.current ||
|
||||
payment.status !== "ready"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialPayChannelAppliedRef.current = true;
|
||||
if (payment.payChannel === initialPayChannel) return;
|
||||
paymentDispatch({
|
||||
type: "PaymentInit",
|
||||
catalog,
|
||||
type: "PaymentPayChannelChanged",
|
||||
payChannel: initialPayChannel,
|
||||
...(characterId ? { characterId } : {}),
|
||||
...(initialCategory ? { category: initialCategory } : {}),
|
||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||
});
|
||||
}, [
|
||||
catalog,
|
||||
catalogKey,
|
||||
characterId,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
initialPayChannel,
|
||||
payment.payChannel,
|
||||
payment.status,
|
||||
paymentDispatch,
|
||||
]);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function shouldInspectPendingPaymentOrder({
|
||||
return (
|
||||
status === "ready" ||
|
||||
(!shouldResumePendingOrder &&
|
||||
(isPollingOrder || isPaid || status === "failed" || status === "expired"))
|
||||
(isPollingOrder || isPaid || status === "failed"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,8 +74,7 @@ export function usePendingPaymentOrderLifecycle({
|
||||
payment.currentOrderId === result.data.orderId &&
|
||||
(payment.isPollingOrder ||
|
||||
payment.isPaid ||
|
||||
payment.status === "failed" ||
|
||||
payment.status === "expired")
|
||||
payment.status === "failed")
|
||||
) {
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
}
|
||||
@@ -109,13 +108,7 @@ export function usePendingPaymentOrderLifecycle({
|
||||
|
||||
useEffect(() => {
|
||||
if (!payment.currentOrderId) return;
|
||||
if (
|
||||
!payment.isPaid &&
|
||||
payment.status !== "failed" &&
|
||||
payment.status !== "expired"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!payment.isPaid && payment.status !== "failed") return;
|
||||
|
||||
void clearPendingPaymentOrder();
|
||||
}, [payment.currentOrderId, payment.isPaid, payment.status]);
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
type PaymentSearchParams,
|
||||
} from "@/lib/payment/payment_search_params";
|
||||
import {
|
||||
normalizeTipGiftParam,
|
||||
TIP_GIFT_CATEGORY_PARAM,
|
||||
TIP_GIFT_PLAN_ID_PARAM,
|
||||
} from "@/lib/tip/tip_gift";
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
resolveTipCoffeeType,
|
||||
TIP_COFFEE_TYPE_PARAM,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import { TipScreen } from "@/app/tip/tip-screen";
|
||||
|
||||
export default async function CharacterTipPage({
|
||||
@@ -17,17 +17,14 @@ export default async function CharacterTipPage({
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const paymentReturn = parsePaymentReturnSearchParams(query);
|
||||
const initialCategory = normalizeTipGiftParam(
|
||||
getFirstPaymentSearchParam(query[TIP_GIFT_CATEGORY_PARAM]),
|
||||
);
|
||||
const initialPlanId = normalizeTipGiftParam(
|
||||
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
|
||||
);
|
||||
const coffeeType =
|
||||
resolveTipCoffeeType(
|
||||
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]),
|
||||
) ?? DEFAULT_TIP_COFFEE_TYPE;
|
||||
|
||||
return (
|
||||
<TipScreen
|
||||
initialCategory={initialCategory}
|
||||
initialPlanId={initialPlanId}
|
||||
coffeeType={coffeeType}
|
||||
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
||||
initialPayChannel={paymentReturn.initialPayChannel}
|
||||
/>
|
||||
|
||||
@@ -10,13 +10,12 @@ import {
|
||||
describe("chat area render items", () => {
|
||||
it("keeps local message keys stable when history is prepended", () => {
|
||||
const localMessage: UiMessage = {
|
||||
displayId: "client:message:local-1",
|
||||
content: "optimistic message",
|
||||
isFromAI: false,
|
||||
date: "2026-07-08",
|
||||
};
|
||||
const historyMessage: UiMessage = {
|
||||
displayId: "history-msg-1",
|
||||
id: "history-msg-1",
|
||||
content: "history message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-07",
|
||||
@@ -29,34 +28,12 @@ describe("chat area render items", () => {
|
||||
getMessageKey,
|
||||
);
|
||||
|
||||
expect(findMessageKey(firstItems, localMessage)).toBe(
|
||||
"msg-client:message:local-1",
|
||||
);
|
||||
expect(findMessageKey(secondItems, localMessage)).toBe(
|
||||
"msg-client:message:local-1",
|
||||
);
|
||||
expect(findMessageKey(firstItems, localMessage)).toBe("local-msg-1");
|
||||
expect(findMessageKey(secondItems, localMessage)).toBe("local-msg-1");
|
||||
expect(findMessageKey(secondItems, historyMessage)).toBe(
|
||||
"msg-history-msg-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the same key after a message object is recreated", () => {
|
||||
const getMessageKey = createChatMessageKeyResolver();
|
||||
const original: UiMessage = {
|
||||
displayId: "server:message-1:assistant",
|
||||
remoteId: "message-1",
|
||||
content: "Original",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
};
|
||||
const updated: UiMessage = {
|
||||
...original,
|
||||
content: "Unlocked",
|
||||
locked: false,
|
||||
};
|
||||
|
||||
expect(getMessageKey(updated)).toBe(getMessageKey(original));
|
||||
});
|
||||
});
|
||||
|
||||
function findMessageKey(
|
||||
|
||||
@@ -9,7 +9,6 @@ import { createChatPromotionState } from "@/stores/chat/helper/promotion";
|
||||
import {
|
||||
resolveChatUnlockReturnUrl,
|
||||
resolveMessageUnlockRequest,
|
||||
resolvePendingUnlockDisplayMessageId,
|
||||
shouldResumePendingChatUnlock,
|
||||
} from "../hooks/use-chat-unlock-coordinator";
|
||||
|
||||
@@ -32,15 +31,11 @@ describe("chat unlock coordinator", () => {
|
||||
it("creates a regular message unlock request with its remote id", () => {
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{
|
||||
displayMessageId: "server:message-1:assistant",
|
||||
remoteMessageId: "message-1",
|
||||
kind: "private",
|
||||
},
|
||||
{ messageId: "message-1", kind: "private" },
|
||||
defaultScope,
|
||||
),
|
||||
).toEqual({
|
||||
displayMessageId: "server:message-1:assistant",
|
||||
displayMessageId: "message-1",
|
||||
messageId: "message-1",
|
||||
kind: "private",
|
||||
returnUrl: "/chat",
|
||||
@@ -56,11 +51,7 @@ describe("chat unlock coordinator", () => {
|
||||
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{
|
||||
displayMessageId: "image-1",
|
||||
remoteMessageId: "image-1",
|
||||
kind: "image",
|
||||
},
|
||||
{ messageId: "image-1", kind: "image" },
|
||||
imageScope,
|
||||
).returnUrl,
|
||||
).toBe("/chat?image=image-1");
|
||||
@@ -71,10 +62,7 @@ describe("chat unlock coordinator", () => {
|
||||
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
kind: "image",
|
||||
},
|
||||
{ messageId: "promotion:promotion-1", kind: "image" },
|
||||
{ ...defaultScope, promotion },
|
||||
),
|
||||
).toEqual({
|
||||
@@ -95,11 +83,7 @@ describe("chat unlock coordinator", () => {
|
||||
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
remoteMessageId: "promotion-message-1",
|
||||
kind: "image",
|
||||
},
|
||||
{ messageId: "promotion-message-1", kind: "image" },
|
||||
{ ...defaultScope, promotion },
|
||||
).messageId,
|
||||
).toBe("promotion-message-1");
|
||||
@@ -119,40 +103,6 @@ describe("chat unlock coordinator", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("maps a legacy pending remote id to the current display identity", () => {
|
||||
const pending = createPendingUnlock({
|
||||
displayMessageId: "message-1",
|
||||
messageId: "message-1",
|
||||
kind: "private",
|
||||
returnUrl: "/chat",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePendingUnlockDisplayMessageId(
|
||||
pending,
|
||||
[
|
||||
{
|
||||
displayId: "server:message-1:user",
|
||||
remoteId: "message-1",
|
||||
content: "Question",
|
||||
isFromAI: false,
|
||||
date: "2026-07-20",
|
||||
},
|
||||
{
|
||||
displayId: "server:message-1:assistant",
|
||||
remoteId: "message-1",
|
||||
content: "Locked reply",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
],
|
||||
null,
|
||||
),
|
||||
).toBe("server:message-1:assistant");
|
||||
});
|
||||
|
||||
it("only resumes an image return when its message matches the overlay", () => {
|
||||
const imageScope = {
|
||||
defaultReturnUrl: "/chat",
|
||||
@@ -186,7 +136,7 @@ describe("chat unlock coordinator", () => {
|
||||
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{ displayMessageId: imageMessageId, kind: "image" },
|
||||
{ messageId: imageMessageId, kind: "image" },
|
||||
{
|
||||
...imageScope,
|
||||
promotion,
|
||||
|
||||
@@ -7,7 +7,20 @@ export type ChatRenderItem =
|
||||
| { type: "msg"; message: UiMessage; key: string };
|
||||
|
||||
export function createChatMessageKeyResolver(): ChatMessageKeyResolver {
|
||||
return (message) => `msg-${message.displayId}`;
|
||||
const localMessageKeys = new WeakMap<UiMessage, string>();
|
||||
let nextLocalMessageKey = 0;
|
||||
|
||||
return (message) => {
|
||||
if (message.id && message.id.length > 0) return `msg-${message.id}`;
|
||||
|
||||
const existing = localMessageKeys.get(message);
|
||||
if (existing) return existing;
|
||||
|
||||
nextLocalMessageKey += 1;
|
||||
const next = `local-msg-${nextLocalMessageKey}`;
|
||||
localMessageKeys.set(message, next);
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChatRenderItems(
|
||||
|
||||
@@ -89,16 +89,13 @@ export function ChatScreen() {
|
||||
() =>
|
||||
imageMessageId
|
||||
? visibleMessages.find(
|
||||
(item) =>
|
||||
(item.displayId === imageMessageId ||
|
||||
item.remoteId === imageMessageId) &&
|
||||
item.imageUrl,
|
||||
(item) => item.id === imageMessageId && item.imageUrl,
|
||||
) ?? null
|
||||
: null,
|
||||
[imageMessageId, visibleMessages],
|
||||
);
|
||||
|
||||
// Guest status belongs to auth state and is not duplicated in the Chat actor.
|
||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
||||
|
||||
// 发送能力由后端统一返回,当前只处理积分不足引导。
|
||||
@@ -178,44 +175,22 @@ export function ChatScreen() {
|
||||
state.characterErrorCode,
|
||||
]);
|
||||
|
||||
function handleUnlockPrivateMessage(
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
): void {
|
||||
unlockCoordinator.requestMessageUnlock({
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
kind: "private",
|
||||
});
|
||||
function handleUnlockPrivateMessage(messageId: string): void {
|
||||
unlockCoordinator.requestMessageUnlock(messageId, "private");
|
||||
}
|
||||
|
||||
function handleUnlockVoiceMessage(
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
): void {
|
||||
unlockCoordinator.requestMessageUnlock({
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
kind: "voice",
|
||||
});
|
||||
function handleUnlockVoiceMessage(messageId: string): void {
|
||||
unlockCoordinator.requestMessageUnlock(messageId, "voice");
|
||||
}
|
||||
|
||||
function handleUnlockImageMessage(
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
): void {
|
||||
unlockCoordinator.requestMessageUnlock({
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
kind: "image",
|
||||
});
|
||||
function handleUnlockImageMessage(messageId: string): void {
|
||||
unlockCoordinator.requestMessageUnlock(messageId, "image");
|
||||
}
|
||||
|
||||
function handleOpenImage(displayMessageId: string): void {
|
||||
router.push(
|
||||
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
||||
{ scroll: false },
|
||||
);
|
||||
function handleOpenImage(messageId: string): void {
|
||||
router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCloseImageViewer(): void {
|
||||
@@ -226,12 +201,8 @@ export function ChatScreen() {
|
||||
}
|
||||
|
||||
function handleUnlockImagePaywall(): void {
|
||||
if (!selectedImageMessage) return;
|
||||
unlockCoordinator.requestMessageUnlock({
|
||||
displayMessageId: selectedImageMessage.displayId,
|
||||
remoteMessageId: selectedImageMessage.remoteId,
|
||||
kind: "image",
|
||||
});
|
||||
if (!imageMessageId) return;
|
||||
unlockCoordinator.requestMessageUnlock(imageMessageId, "image");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -251,6 +222,7 @@ export function ChatScreen() {
|
||||
</div>
|
||||
|
||||
<div className="relative z-1 flex min-h-0 flex-[1_1_auto] flex-col">
|
||||
{/* isGuest 派生自 auth.loginStatus */}
|
||||
<ChatHeader
|
||||
isGuest={isGuest}
|
||||
showBrowserHint={shouldShowBrowserHint}
|
||||
@@ -307,12 +279,12 @@ export function ChatScreen() {
|
||||
{selectedImageMessage?.imageUrl ? (
|
||||
<FullscreenImageViewer
|
||||
characterId={state.characterId}
|
||||
remoteMessageId={selectedImageMessage.remoteId}
|
||||
messageId={selectedImageMessage.id}
|
||||
imageUrl={selectedImageMessage.imageUrl}
|
||||
imagePaywalled={selectedImageMessage.imagePaywalled === true}
|
||||
isUnlockingImagePaywall={
|
||||
state.isUnlockingMessage &&
|
||||
state.unlockingMessageId === selectedImageMessage.displayId
|
||||
state.unlockingMessageId === selectedImageMessage.id
|
||||
}
|
||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||
onClose={handleCloseImageViewer}
|
||||
|
||||
@@ -265,19 +265,19 @@ function createTouchEvent(
|
||||
return event;
|
||||
}
|
||||
|
||||
function createMessage(displayId: string): UiMessage {
|
||||
function createMessage(id: string): UiMessage {
|
||||
return {
|
||||
displayId,
|
||||
content: `Message ${displayId}`,
|
||||
id,
|
||||
content: `Message ${id}`,
|
||||
isFromAI: true,
|
||||
date: "2026-07-15",
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(displayId: string): UiMessage {
|
||||
function createUserMessage(id: string): UiMessage {
|
||||
return {
|
||||
displayId,
|
||||
content: `Message ${displayId}`,
|
||||
id,
|
||||
content: `Message ${id}`,
|
||||
isFromAI: false,
|
||||
date: "2026-07-15",
|
||||
};
|
||||
|
||||
@@ -128,8 +128,7 @@ describe("chat Tailwind components", () => {
|
||||
const openableHtml = renderToStaticMarkup(
|
||||
<ImageBubble
|
||||
characterId="elio"
|
||||
displayMessageId="server:message-1:assistant"
|
||||
remoteMessageId="message-1"
|
||||
messageId="message-1"
|
||||
imageUrl="/chat-image.png"
|
||||
onOpenImage={() => undefined}
|
||||
/>,
|
||||
@@ -137,7 +136,6 @@ describe("chat Tailwind components", () => {
|
||||
const paywalledHtml = renderToStaticMarkup(
|
||||
<ImageBubble
|
||||
characterId="elio"
|
||||
displayMessageId="server:message-2:assistant"
|
||||
imageUrl="/locked-image.png"
|
||||
imagePaywalled
|
||||
/>,
|
||||
|
||||
@@ -54,18 +54,13 @@ export interface ChatAreaProps {
|
||||
isLoadingMoreHistory?: boolean;
|
||||
isUnlockingMessage?: boolean;
|
||||
unlockingMessageId?: string | null;
|
||||
onUnlockPrivateMessage?: ChatMessageAction;
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
onUnlockImageMessage?: ChatMessageAction;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
onLoadMoreHistory?: () => void;
|
||||
}
|
||||
|
||||
type ChatMessageAction = (
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
) => void;
|
||||
|
||||
export function ChatArea({
|
||||
characterId,
|
||||
messages,
|
||||
@@ -283,10 +278,10 @@ function renderMessagesWithDateHeaders(
|
||||
getMessageKey: ChatMessageKeyResolver,
|
||||
isUnlockingMessage?: boolean,
|
||||
unlockingMessageId?: string | null,
|
||||
onUnlockPrivateMessage?: ChatMessageAction,
|
||||
onUnlockVoiceMessage?: ChatMessageAction,
|
||||
onUnlockImageMessage?: ChatMessageAction,
|
||||
onOpenImage?: (displayMessageId: string) => void,
|
||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
||||
onUnlockImageMessage?: (messageId: string) => void,
|
||||
onOpenImage?: (messageId: string) => void,
|
||||
) {
|
||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||
item.type === "date" ? (
|
||||
@@ -295,8 +290,7 @@ function renderMessagesWithDateHeaders(
|
||||
<MessageBubble
|
||||
characterId={characterId}
|
||||
key={item.key}
|
||||
displayMessageId={item.message.displayId}
|
||||
remoteMessageId={item.message.remoteId}
|
||||
messageId={item.message.id}
|
||||
content={item.message.content}
|
||||
imageUrl={item.message.imageUrl}
|
||||
imagePaywalled={item.message.imagePaywalled}
|
||||
@@ -308,7 +302,7 @@ function renderMessagesWithDateHeaders(
|
||||
privateMessageHint={item.message.privateMessageHint}
|
||||
isUnlockingMessage={
|
||||
isUnlockingMessage === true &&
|
||||
item.message.displayId === unlockingMessageId
|
||||
item.message.id === unlockingMessageId
|
||||
}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
* - 多行输入(min 1 行 / max 5 行 ≈ max-height 120px)
|
||||
* - Enter 直接发送(无 Shift/Ctrl/Meta)
|
||||
* - Shift+Enter / Ctrl+Enter / Meta+Enter 换行
|
||||
* - 外层白底圆角容器
|
||||
* - 外层白底圆角容器(与 Dart `Container(borderRadius: AppRadius.xxxl ≈ 32)` 一致)
|
||||
*
|
||||
* 通过 `forwardRef` 暴露 textarea 节点,父组件可在 send 后调用 `.focus()`
|
||||
* (对齐 Dart `_focusNode.requestFocus()`)。
|
||||
* 通过 `onFocusChange` 回调通知父组件焦点状态,用于外层容器的聚焦样式切换。
|
||||
*/
|
||||
import {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||
|
||||
export interface ChatMediaImageProps {
|
||||
characterId: string;
|
||||
remoteMessageId?: string;
|
||||
messageId?: string;
|
||||
remoteUrl: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
@@ -30,7 +30,7 @@ export interface ChatMediaImageProps {
|
||||
|
||||
export function ChatMediaImage({
|
||||
characterId,
|
||||
remoteMessageId,
|
||||
messageId,
|
||||
remoteUrl,
|
||||
alt = "",
|
||||
className,
|
||||
@@ -48,7 +48,7 @@ export function ChatMediaImage({
|
||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||
useCachedChatMediaUrl({
|
||||
characterId,
|
||||
messageId: remoteMessageId,
|
||||
messageId,
|
||||
remoteUrl,
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import styles from "./fullscreen-image-viewer.module.css";
|
||||
|
||||
export interface FullscreenImageViewerProps {
|
||||
characterId: string;
|
||||
remoteMessageId?: string;
|
||||
messageId?: string;
|
||||
imageUrl: string;
|
||||
imagePaywalled?: boolean;
|
||||
isUnlockingImagePaywall?: boolean;
|
||||
@@ -29,7 +29,7 @@ export interface FullscreenImageViewerProps {
|
||||
|
||||
export function FullscreenImageViewer({
|
||||
characterId,
|
||||
remoteMessageId,
|
||||
messageId,
|
||||
imageUrl,
|
||||
imagePaywalled = false,
|
||||
isUnlockingImagePaywall = false,
|
||||
@@ -54,7 +54,7 @@ export function FullscreenImageViewer({
|
||||
>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.paywallImage}
|
||||
nativeClassName={`${styles.nativePaywallImage} ${styles.paywallImage}`}
|
||||
@@ -104,7 +104,7 @@ export function FullscreenImageViewer({
|
||||
/>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.viewerImage}
|
||||
errorClassName="error"
|
||||
|
||||
@@ -12,22 +12,20 @@ import { ChatMediaImage } from "./chat-media-image";
|
||||
|
||||
export interface ImageBubbleProps {
|
||||
characterId: string;
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
messageId?: string;
|
||||
imageUrl: string; // base64 data URI 或 URL
|
||||
imagePaywalled?: boolean;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function ImageBubble({
|
||||
characterId,
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
messageId,
|
||||
imageUrl,
|
||||
imagePaywalled = false,
|
||||
onOpenImage,
|
||||
}: ImageBubbleProps) {
|
||||
const canOpen = Boolean(onOpenImage);
|
||||
const canOpen = Boolean(messageId && onOpenImage);
|
||||
const imageClassName = [
|
||||
"block h-auto w-full object-cover",
|
||||
imagePaywalled ? "scale-104 blur-sm" : "",
|
||||
@@ -36,8 +34,8 @@ export function ImageBubble({
|
||||
.join(" ");
|
||||
|
||||
const openImage = () => {
|
||||
if (!onOpenImage) return;
|
||||
onOpenImage(displayMessageId);
|
||||
if (!messageId || !onOpenImage) return;
|
||||
onOpenImage(messageId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,7 +55,7 @@ export function ImageBubble({
|
||||
>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={imageClassName}
|
||||
errorClassName="flex size-(--chat-media-size,220px) items-center justify-center bg-(--color-bubble-background,#fff) text-(length:--icon-size-xl,24px) text-(--color-text-secondary,#9e9e9e)"
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
"use client";
|
||||
/** CSS typing indicator loaded lazily with the rest of the reply UI. */
|
||||
/**
|
||||
* LottieMessageBubble 动画消息气泡
|
||||
*
|
||||
*
|
||||
*
|
||||
* 原始实现:使用 lottie SDK 渲染"打字指示器"动画
|
||||
* 本轮实现:使用 CSS keyframes 模拟(无 lottie 依赖)
|
||||
* - 3 个跳动的圆点(左右 + 中间)
|
||||
* - 1.4s 周期,stagger 动画延迟
|
||||
*
|
||||
* 行为一致(视觉类似 lottie 的"三点跳跃"动画)
|
||||
*/
|
||||
import { MessageAvatar } from "./message-avatar";
|
||||
import styles from "./lottie-message-bubble.module.css";
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ import styles from "./chat-area.module.css";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
characterId: string;
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
messageId?: string;
|
||||
content: string;
|
||||
imageUrl?: string | null;
|
||||
imagePaywalled?: boolean;
|
||||
@@ -28,21 +27,15 @@ export interface MessageBubbleProps {
|
||||
lockedPrivate?: boolean | null;
|
||||
privateMessageHint?: string | null;
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: ChatMessageAction;
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
onUnlockImageMessage?: ChatMessageAction;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
type ChatMessageAction = (
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
) => void;
|
||||
|
||||
export function MessageBubble({
|
||||
characterId,
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
messageId,
|
||||
content,
|
||||
imageUrl,
|
||||
imagePaywalled,
|
||||
@@ -64,19 +57,18 @@ export function MessageBubble({
|
||||
return (
|
||||
<div
|
||||
className={styles.bubbleRowAi}
|
||||
data-chat-message-id={displayMessageId}
|
||||
data-chat-message-id={messageId}
|
||||
aria-label="AI message"
|
||||
>
|
||||
<MessageAvatar isFromAI={true} />
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
<MessageContent
|
||||
characterId={characterId}
|
||||
displayMessageId={displayMessageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
audioUrl={audioUrl}
|
||||
messageId={messageId}
|
||||
isFromAI={true}
|
||||
locked={locked}
|
||||
lockReason={lockReason}
|
||||
@@ -96,18 +88,17 @@ export function MessageBubble({
|
||||
return (
|
||||
<div
|
||||
className={styles.bubbleRowUser}
|
||||
data-chat-message-id={displayMessageId}
|
||||
data-chat-message-id={messageId}
|
||||
aria-label="User message"
|
||||
>
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
<MessageContent
|
||||
characterId={characterId}
|
||||
displayMessageId={displayMessageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
audioUrl={audioUrl}
|
||||
messageId={messageId}
|
||||
isFromAI={false}
|
||||
locked={locked}
|
||||
lockReason={lockReason}
|
||||
|
||||
@@ -8,39 +8,32 @@ import styles from "./chat-area.module.css";
|
||||
|
||||
export interface MessageContentProps {
|
||||
characterId: string;
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
content: string;
|
||||
imageUrl?: string | null;
|
||||
imagePaywalled?: boolean;
|
||||
audioUrl?: string | null;
|
||||
messageId?: string;
|
||||
isFromAI: boolean;
|
||||
locked?: boolean | null;
|
||||
lockReason?: string | null;
|
||||
lockedPrivate?: boolean | null;
|
||||
privateMessageHint?: string | null;
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: ChatMessageAction;
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
onUnlockImageMessage?: ChatMessageAction;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
type ChatMessageAction = (
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
) => void;
|
||||
|
||||
const IMAGE_PLACEHOLDER = "[图片]";
|
||||
|
||||
export function MessageContent({
|
||||
characterId,
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
content,
|
||||
imageUrl,
|
||||
imagePaywalled,
|
||||
audioUrl,
|
||||
messageId,
|
||||
isFromAI,
|
||||
locked,
|
||||
lockReason,
|
||||
@@ -64,17 +57,16 @@ export function MessageContent({
|
||||
(lockReason === "image_paywall" || lockReason === "image");
|
||||
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
|
||||
const handleUnlockPrivateMessage =
|
||||
onUnlockPrivateMessage
|
||||
? () =>
|
||||
onUnlockPrivateMessage(displayMessageId, remoteMessageId)
|
||||
messageId && onUnlockPrivateMessage
|
||||
? () => onUnlockPrivateMessage(messageId)
|
||||
: undefined;
|
||||
const handleUnlockVoiceMessage =
|
||||
isLockedVoiceMessage && onUnlockVoiceMessage
|
||||
? () => onUnlockVoiceMessage(displayMessageId, remoteMessageId)
|
||||
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
|
||||
? () => onUnlockVoiceMessage(messageId)
|
||||
: undefined;
|
||||
const handleUnlockImageMessage =
|
||||
isLockedImageMessage && onUnlockImageMessage
|
||||
? () => onUnlockImageMessage(displayMessageId, remoteMessageId)
|
||||
isLockedImageMessage && messageId && onUnlockImageMessage
|
||||
? () => onUnlockImageMessage(messageId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
@@ -101,8 +93,7 @@ export function MessageContent({
|
||||
{hasImage && imageUrl && (
|
||||
<ImageBubble
|
||||
characterId={characterId}
|
||||
displayMessageId={displayMessageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
messageId={messageId}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
onOpenImage={onOpenImage}
|
||||
@@ -111,7 +102,7 @@ export function MessageContent({
|
||||
{shouldRenderVoiceMessage ? (
|
||||
<VoiceBubble
|
||||
characterId={characterId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
messageId={messageId}
|
||||
audioUrl={audioUrl}
|
||||
isFromAI={isFromAI}
|
||||
locked={isLockedVoiceMessage}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
*
|
||||
* 用途:引导用户将 Web App 安装到桌面
|
||||
* 触发:由 PwaInstallOverlay 在满足安装条件后延迟显示
|
||||
* 触发:由 PwaInstallOverlay 在延迟 3.5s 后自动显示
|
||||
*/
|
||||
import Image from "next/image";
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
* PwaInstallOverlay PWA 安装提示触发器
|
||||
*
|
||||
* 行为:
|
||||
* - 调用方达到消息阈值后启用检查
|
||||
* - 仅在浏览器可安装、尚未安装且未展示过时继续
|
||||
* - 延迟 1.5 秒显示 PwaInstallDialog
|
||||
* - 用户确认后调用浏览器安装提示
|
||||
* - 等聊天消息数量达到触发阈值后才开始检查
|
||||
* - 检查 PWA 支持(pwaUtil.isSupported)
|
||||
* - 检查 PWA 是否已安装(pwaUtil.isInstalled)—— 已安装则不弹
|
||||
* - 检查是否已经显示过(使用 AppStorage 持久化)
|
||||
* - 延迟后弹出 PwaInstallDialog
|
||||
* - 生产环境有效;开发环境 1s 后立即弹出(测试用)
|
||||
*
|
||||
* 注意:PWA install prompt API(`beforeinstallprompt`)在浏览器差异较大,
|
||||
* 本轮仅实现"显示时机"逻辑,具体 install prompt 调用由 PwaInstallDialog 处理。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -18,7 +23,8 @@ import { pwaUtil } from "@/utils/pwa";
|
||||
|
||||
import { PwaInstallDialog } from "./pwa-install-dialog";
|
||||
|
||||
const DIALOG_DELAY_MS = 1500;
|
||||
const DEVELOPMENT_DIALOG_DELAY_MS = 1000;
|
||||
const PRODUCTION_DIALOG_DELAY_MS = 1500;
|
||||
|
||||
export interface PwaInstallOverlayProps {
|
||||
enabled: boolean;
|
||||
@@ -33,18 +39,24 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const init = async () => {
|
||||
// const isDev = AppEnvUtil.isDevelopment();
|
||||
const isDev = false;
|
||||
|
||||
// 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。
|
||||
pwaUtil.prepareInstallPrompt();
|
||||
|
||||
const tryScheduleDialog = async () => {
|
||||
const shouldShowDialog = await shouldShowPwaInstallDialog();
|
||||
const shouldShowDialog = await shouldShowPwaInstallDialog(isDev);
|
||||
if (!mounted || !shouldShowDialog || timer !== undefined) return;
|
||||
|
||||
// 达到聊天数量阈值后稍作停顿,避免弹窗与消息刷新挤在同一瞬间。
|
||||
const delay = isDev
|
||||
? DEVELOPMENT_DIALOG_DELAY_MS
|
||||
: PRODUCTION_DIALOG_DELAY_MS;
|
||||
timer = window.setTimeout(() => {
|
||||
if (!mounted) return;
|
||||
showPwaDialog();
|
||||
}, DIALOG_DELAY_MS);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
await tryScheduleDialog();
|
||||
@@ -64,18 +76,26 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function shouldShowPwaInstallDialog() {
|
||||
async function shouldShowPwaInstallDialog(isDevelopment: boolean) {
|
||||
// PWA 支持 / 安装 双重门禁(两种环境都检查)。
|
||||
if (!pwaUtil.canShowInstallEntry()) return false;
|
||||
|
||||
// 开发环境:跳过"只弹一次"门禁,每次进入都弹,方便 UI 测试。
|
||||
if (isDevelopment) return true;
|
||||
|
||||
// 生产环境:永久只弹一次(防骚扰)。
|
||||
return canShowPwaInstallPromptOnce();
|
||||
}
|
||||
|
||||
function showPwaDialog() {
|
||||
if (typeof document === "undefined") return;
|
||||
// Persist before rendering so remounts cannot schedule a second dialog.
|
||||
// 记录已显示,后续不再弹出。
|
||||
void recordPwaInstallPromptShown();
|
||||
|
||||
// 创建挂载点
|
||||
const root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
// 用 React DOM 渲染(通过 import)
|
||||
import("react-dom/client").then(({ createRoot }) => {
|
||||
const reactRoot = createRoot(root);
|
||||
reactRoot.render(<PwaInstallDialogMount rootEl={root} reactRoot={reactRoot} />);
|
||||
@@ -96,7 +116,13 @@ function PwaInstallDialogMount({
|
||||
rootEl.remove();
|
||||
}}
|
||||
onInstall={async () => {
|
||||
// The utility owns deferred-prompt lookup and browser prompting.
|
||||
// 走 pwaUtil.install():
|
||||
// - 优先使用已缓存的 deferred prompt
|
||||
// - 如未缓存,再等待 deferred 事件 ready(3s 超时)
|
||||
// - 调浏览器原生 prompt
|
||||
// - 返 "accepted" / "dismissed" / "unavailable"
|
||||
// 当前不消费返回值 —— 用户已经看到 dialog 点了 OK,是否真装上是浏览器的事。
|
||||
// 后续可在此接 metrics 上报(pwa_accepted / pwa_dismissed / pwa_unavailable)。
|
||||
await pwaUtil.install();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import styles from "./voice-bubble.module.css";
|
||||
|
||||
export interface VoiceBubbleProps {
|
||||
characterId: string;
|
||||
remoteMessageId?: string;
|
||||
messageId?: string;
|
||||
audioUrl?: string | null;
|
||||
isFromAI: boolean;
|
||||
locked?: boolean;
|
||||
@@ -20,7 +20,7 @@ export interface VoiceBubbleProps {
|
||||
|
||||
export function VoiceBubble({
|
||||
characterId,
|
||||
remoteMessageId,
|
||||
messageId,
|
||||
audioUrl,
|
||||
isFromAI,
|
||||
locked = false,
|
||||
@@ -36,7 +36,7 @@ export function VoiceBubble({
|
||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||
useCachedChatMediaUrl({
|
||||
characterId,
|
||||
messageId: remoteMessageId,
|
||||
messageId,
|
||||
remoteUrl: audioUrl,
|
||||
kind: "audio",
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@/stores/chat/chat-context";
|
||||
import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
|
||||
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
|
||||
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
||||
@@ -61,11 +60,10 @@ export interface UseChatUnlockCoordinatorInput
|
||||
}
|
||||
|
||||
export interface UseChatUnlockCoordinatorOutput {
|
||||
requestMessageUnlock: (input: {
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
}) => void;
|
||||
requestMessageUnlock: (
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
) => void;
|
||||
dialogs: ChatUnlockDialogModel;
|
||||
}
|
||||
|
||||
@@ -83,7 +81,6 @@ export function useChatUnlockCoordinator({
|
||||
(state) => ({
|
||||
characterId: state.context.characterId,
|
||||
historyLoaded: state.context.historyLoaded,
|
||||
messages: state.context.messages,
|
||||
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
||||
lockedHistoryCount: state.context.lockedHistoryCount,
|
||||
unlockHistoryError: state.context.unlockHistoryError,
|
||||
@@ -144,15 +141,10 @@ export function useChatUnlockCoordinator({
|
||||
|
||||
const consumed = await consumePendingChatUnlock(chatState.characterId);
|
||||
if (cancelled || !consumed) return;
|
||||
const displayMessageId = resolvePendingUnlockDisplayMessageId(
|
||||
consumed,
|
||||
chatState.messages,
|
||||
promotion,
|
||||
);
|
||||
|
||||
chatDispatch({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId,
|
||||
messageId: consumed.displayMessageId,
|
||||
remoteMessageId: consumed.messageId,
|
||||
kind: consumed.kind,
|
||||
lockType: consumed.lockType,
|
||||
@@ -173,17 +165,14 @@ export function useChatUnlockCoordinator({
|
||||
imageMessageId,
|
||||
imageReturnUrl,
|
||||
navigator.isAuthenticatedUser,
|
||||
promotion,
|
||||
chatState.messages,
|
||||
]);
|
||||
|
||||
function requestMessageUnlock(input: {
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
}): void {
|
||||
function requestMessageUnlock(
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
): void {
|
||||
const request = resolveMessageUnlockRequest(
|
||||
input,
|
||||
{ messageId, kind },
|
||||
{
|
||||
defaultReturnUrl,
|
||||
imageMessageId,
|
||||
@@ -197,7 +186,7 @@ export function useChatUnlockCoordinator({
|
||||
onAuthenticated: () => {
|
||||
chatDispatch({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: request.displayMessageId,
|
||||
messageId: request.displayMessageId,
|
||||
remoteMessageId: request.messageId,
|
||||
kind: request.kind,
|
||||
lockType: request.lockType,
|
||||
@@ -269,21 +258,23 @@ export function useChatUnlockCoordinator({
|
||||
|
||||
export function resolveMessageUnlockRequest(
|
||||
input: {
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
},
|
||||
scope: ChatUnlockCoordinatorScope,
|
||||
): CoordinatedMessageUnlockRequest {
|
||||
const matchedPromotion =
|
||||
scope.promotion?.message.displayId === input.displayMessageId
|
||||
scope.promotion?.message.id === input.messageId
|
||||
? scope.promotion
|
||||
: null;
|
||||
const remoteMessageId =
|
||||
input.remoteMessageId ?? matchedPromotion?.message.remoteId;
|
||||
const temporaryPromotionMessageId = matchedPromotion
|
||||
? `promotion:${matchedPromotion.session.clientLockId}`
|
||||
: null;
|
||||
const target = {
|
||||
displayMessageId: input.displayMessageId,
|
||||
...(remoteMessageId ? { messageId: remoteMessageId } : {}),
|
||||
displayMessageId: input.messageId,
|
||||
...(input.messageId !== temporaryPromotionMessageId
|
||||
? { messageId: input.messageId }
|
||||
: {}),
|
||||
kind: input.kind,
|
||||
...(matchedPromotion
|
||||
? {
|
||||
@@ -309,40 +300,6 @@ export function shouldResumePendingChatUnlock(
|
||||
return isCurrentImageUnlock(pending, scope.imageMessageId);
|
||||
}
|
||||
|
||||
export function resolvePendingUnlockDisplayMessageId(
|
||||
pending: PendingChatUnlock,
|
||||
messages: readonly UiMessage[],
|
||||
promotion: ChatPromotionState | null,
|
||||
): string {
|
||||
if (
|
||||
promotion &&
|
||||
(promotion.message.displayId === pending.displayMessageId ||
|
||||
(pending.messageId &&
|
||||
promotion.message.remoteId === pending.messageId))
|
||||
) {
|
||||
return promotion.message.displayId;
|
||||
}
|
||||
|
||||
const exactMessage = messages.find(
|
||||
(message) => message.displayId === pending.displayMessageId,
|
||||
);
|
||||
if (exactMessage) return exactMessage.displayId;
|
||||
if (!pending.messageId) return pending.displayMessageId;
|
||||
|
||||
const remoteMessage =
|
||||
messages.find(
|
||||
(message) =>
|
||||
message.isFromAI &&
|
||||
message.locked === true &&
|
||||
message.remoteId === pending.messageId,
|
||||
) ??
|
||||
messages.find(
|
||||
(message) =>
|
||||
message.isFromAI && message.remoteId === pending.messageId,
|
||||
);
|
||||
return remoteMessage?.displayId ?? pending.displayMessageId;
|
||||
}
|
||||
|
||||
export function resolveChatUnlockReturnUrl(
|
||||
target: Pick<
|
||||
CoordinatedMessageUnlockRequest,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/* @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");
|
||||
});
|
||||
});
|
||||
@@ -1,92 +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 {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
/* @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 {
|
||||
GiftCategorySchema,
|
||||
GiftProductSchema,
|
||||
PaymentPlanSchema,
|
||||
TipMessageResponseSchema,
|
||||
type PaymentPlan,
|
||||
} from "@/data/schemas/payment";
|
||||
import type { PaymentContextState } from "@/stores/payment/payment-context";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
payment: null as unknown as PaymentContextState,
|
||||
paymentDispatch: vi.fn(),
|
||||
planClick: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_components", () => ({
|
||||
BackButton: () => <span data-testid="tip-back-button" />,
|
||||
CharacterAvatar: ({ alt }: { alt: string }) => (
|
||||
<span data-testid="tip-character-avatar">{alt}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/_components/core", () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/app/_components/payment/payment-method-selector", () => ({
|
||||
PaymentMethodSelector: ({ density }: { density?: string }) => (
|
||||
<span data-testid="tip-payment-method" data-density={density} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/_hooks/use-payment-method-selection", () => ({
|
||||
usePaymentMethodSelection: () => undefined,
|
||||
}));
|
||||
vi.mock("@/app/_hooks/use-payment-plan-analytics", () => ({
|
||||
usePaymentPlanAnalytics: () => undefined,
|
||||
}));
|
||||
vi.mock("@/app/_hooks/use-payment-route-flow", () => ({
|
||||
usePaymentRouteFlow: () => ({
|
||||
payment: mocks.payment,
|
||||
paymentDispatch: mocks.paymentDispatch,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/analytics", () => ({
|
||||
behaviorAnalytics: { planClick: mocks.planClick },
|
||||
}));
|
||||
vi.mock("@/providers/character-provider", () => ({
|
||||
useActiveCharacter: () => ({
|
||||
id: "maya-tan",
|
||||
slug: "maya",
|
||||
displayName: "Maya Tan",
|
||||
assets: {
|
||||
avatar: "/images/avatar/maya.png",
|
||||
cover: "/images/cover/maya.png",
|
||||
},
|
||||
copy: {
|
||||
tipHeader: "Tip Maya",
|
||||
tipTitle: "Send Maya a gift",
|
||||
},
|
||||
}),
|
||||
useActiveCharacterRoutes: () => ({
|
||||
splash: "/characters/maya/splash",
|
||||
tip: "/characters/maya/tip",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/stores/user/user-context", () => ({
|
||||
useUserState: () => ({ currentUser: null }),
|
||||
}));
|
||||
vi.mock("../tip-checkout-button", () => ({
|
||||
TipCheckoutButton: ({
|
||||
disabled,
|
||||
onOrder,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onOrder: () => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tip-checkout"
|
||||
disabled={disabled}
|
||||
onClick={onOrder}
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("../tip-gift-product-selector", () => ({
|
||||
TipGiftProductSelector: ({
|
||||
products,
|
||||
}: {
|
||||
products: readonly { planId: string; planName: string }[];
|
||||
}) => (
|
||||
<span data-testid="tip-product-selector">
|
||||
{products.map((product) => product.planName).join(",")}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("../tip-product-image", () => ({
|
||||
TipProductImage: ({ alt }: { alt: string }) => (
|
||||
<span data-testid="tip-product-image">{alt}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("../use-tip-support-prompt", () => ({
|
||||
useTipSupportPrompt: () => ({
|
||||
prompt: "A warm coffee prompt.",
|
||||
isReady: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TipScreen } from "../tip-screen";
|
||||
|
||||
const giftCategory = GiftCategorySchema.parse({
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
});
|
||||
|
||||
const giftProduct = GiftProductSchema.parse({
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
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",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
creditBalance: 0,
|
||||
amountCents: giftProduct.amountCents,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: giftProduct.currency,
|
||||
});
|
||||
|
||||
describe("TipScreen checkout", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mocks.payment = makePaymentState();
|
||||
mocks.paymentDispatch.mockReset();
|
||||
mocks.planClick.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("creates a dynamic character-attributed gift order", () => {
|
||||
renderScreen();
|
||||
const checkout = getCheckoutButton();
|
||||
|
||||
expect(container.textContent).toContain("Golden Reserve");
|
||||
expect(checkout.disabled).toBe(false);
|
||||
act(() => checkout.click());
|
||||
|
||||
expect(mocks.planClick).toHaveBeenCalledWith(
|
||||
giftPlan,
|
||||
expect.objectContaining({ entryPoint: "tip_page" }),
|
||||
);
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
recipientCharacterId: "maya-tan",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the enlarged purchase flow without a character Cover", () => {
|
||||
renderScreen();
|
||||
|
||||
expect(container.textContent).toContain("Send Maya a gift");
|
||||
expect(container.textContent).toContain("A warm coffee prompt.");
|
||||
expect(container.querySelector('[data-testid="tip-back-button"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="tip-character-avatar"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="tip-product-image"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="tip-product-selector"]')).not.toBeNull();
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-testid="tip-payment-method"]')
|
||||
?.getAttribute("data-density"),
|
||||
).toBe("compact");
|
||||
expect(container.textContent).not.toContain("A little sweetness for today");
|
||||
expect(container.textContent).not.toContain("Tip Maya");
|
||||
expect(container.querySelector("main")?.getAttribute("style")).toBeNull();
|
||||
expect(
|
||||
container.querySelectorAll('main > div[aria-hidden="true"]'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an order is being created", { isCreatingOrder: true }],
|
||||
["an order is being polled", { isPollingOrder: true }],
|
||||
] as const)("disables checkout when %s", (_label, overrides) => {
|
||||
mocks.payment = makePaymentState(overrides);
|
||||
renderScreen();
|
||||
expect(getCheckoutButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["catalog is loading", { isLoadingPlans: true }],
|
||||
[
|
||||
"the catalog is empty",
|
||||
{ plans: [], giftProducts: [], selectedPlanId: "" },
|
||||
],
|
||||
] as const)("hides payment controls when %s", (_label, overrides) => {
|
||||
mocks.payment = makePaymentState(overrides);
|
||||
renderScreen();
|
||||
|
||||
expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="tip-payment-method"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a compact retry state when the catalog fails", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
plans: [],
|
||||
giftProducts: [],
|
||||
selectedPlanId: "",
|
||||
errorMessage: "catalog unavailable",
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"We could not load gifts for this character.",
|
||||
);
|
||||
act(() => getButton("Try again").click());
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCatalogRetryRequested",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows fixed copy for the first character Tip", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "paid",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipMessage: TipMessageResponseSchema.parse({
|
||||
orderId: "pay_first",
|
||||
characterId: "maya-tan",
|
||||
planId: giftProduct.planId,
|
||||
productName: giftProduct.planName,
|
||||
tipCount: 1,
|
||||
poolIndex: 4,
|
||||
message: "This backend message is ignored for the first Tip.",
|
||||
}),
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"Did you really just buy me a coffee?",
|
||||
);
|
||||
expect(container.textContent).toContain("That honestly made me smile.");
|
||||
expect(container.textContent).toContain(
|
||||
"I'll definitely think of you while I enjoy it.",
|
||||
);
|
||||
expect(container.textContent).not.toContain(
|
||||
"This backend message is ignored for the first Tip.",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the backend message after the first Tip", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "paid",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipMessage: TipMessageResponseSchema.parse({
|
||||
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();
|
||||
|
||||
expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
"This complete message came directly from the backend.",
|
||||
);
|
||||
expect(container.textContent).not.toContain(
|
||||
"Did you really just buy me a coffee?",
|
||||
);
|
||||
expect(document.activeElement?.id).toBe("tip-success-title");
|
||||
|
||||
act(() => getButton("Send another gift").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({
|
||||
type: "PaymentTipMessageRetryRequested",
|
||||
});
|
||||
});
|
||||
|
||||
function renderScreen(): void {
|
||||
act(() => root.render(<TipScreen />));
|
||||
}
|
||||
|
||||
function getCheckoutButton(): HTMLButtonElement {
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="tip-checkout"]',
|
||||
);
|
||||
if (!button) throw new Error("Missing Tip checkout button");
|
||||
return button;
|
||||
}
|
||||
|
||||
function getButton(label: string): HTMLButtonElement {
|
||||
const button = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((item) => item.textContent?.trim() === label);
|
||||
if (!button) throw new Error(`Missing button: ${label}`);
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
function makePaymentState(
|
||||
overrides: Partial<PaymentContextState> = {},
|
||||
): PaymentContextState {
|
||||
return {
|
||||
status: "ready",
|
||||
planCatalog: "tip",
|
||||
plans: [giftPlan],
|
||||
giftCategories: [giftCategory],
|
||||
giftProducts: [giftProduct],
|
||||
selectedGiftCategory: giftCategory.category,
|
||||
isFirstRecharge: false,
|
||||
selectedPlanId: giftPlan.planId,
|
||||
payChannel: "stripe",
|
||||
autoRenew: false,
|
||||
agreed: true,
|
||||
currentOrderId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
errorMessage: null,
|
||||
launchNonce: 0,
|
||||
isLoadingPlans: false,
|
||||
isLoadingTipMessage: false,
|
||||
isCreatingOrder: false,
|
||||
isPollingOrder: false,
|
||||
isPaid: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,92 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GiftCategorySchema,
|
||||
GiftProductSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment";
|
||||
import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
|
||||
|
||||
import {
|
||||
formatGiftPrice,
|
||||
getGiftImageSources,
|
||||
TIP_GIFT_PLACEHOLDER_IMAGE,
|
||||
findTipCoffeePlan,
|
||||
formatTipPrice,
|
||||
isRealLoginStatus,
|
||||
} from "../tip-screen.helpers";
|
||||
|
||||
const category = GiftCategorySchema.parse({
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: "https://cdn.example.com/category.jpg",
|
||||
});
|
||||
|
||||
const product = GiftProductSchema.parse({
|
||||
planId: "gift_1",
|
||||
planName: "Golden Reserve",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_medium",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "A warm coffee",
|
||||
imageUrl: "https://cdn.example.com/product.jpg",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
});
|
||||
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
|
||||
return PaymentPlanSchema.parse({
|
||||
planId: "coins_100",
|
||||
planName: "Coins",
|
||||
orderType: "dol",
|
||||
vipDays: null,
|
||||
dolAmount: 100,
|
||||
creditBalance: 100,
|
||||
amountCents: 990,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: "USD",
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: null,
|
||||
promotionType: null,
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
describe("tip screen helpers", () => {
|
||||
it("formats prices from backend amount and currency", () => {
|
||||
expect(formatGiftPrice(999, "usd")).toBe("$9.99");
|
||||
expect(formatGiftPrice(500, "PHP")).toContain("5.00");
|
||||
it("matches the official small coffee plan", () => {
|
||||
const plan = makePlan({
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
});
|
||||
|
||||
expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan);
|
||||
});
|
||||
|
||||
it("orders product, category, and local image fallbacks", () => {
|
||||
expect(getGiftImageSources(product, category)).toEqual([
|
||||
"https://cdn.example.com/product.jpg",
|
||||
"https://cdn.example.com/category.jpg",
|
||||
TIP_GIFT_PLACEHOLDER_IMAGE,
|
||||
]);
|
||||
it("matches medium and large coffee plans independently", () => {
|
||||
const medium = makePlan({
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
});
|
||||
const large = makePlan({
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
orderType: "tip",
|
||||
amountCents: 1999,
|
||||
});
|
||||
|
||||
expect(findTipCoffeePlan([large, medium], "medium")).toBe(medium);
|
||||
expect(findTipCoffeePlan([medium, large], "large")).toBe(large);
|
||||
});
|
||||
|
||||
it("always retains the local placeholder", () => {
|
||||
it("does not infer a coffee tier from order type or amount", () => {
|
||||
const ambiguousPlan = makePlan({
|
||||
planId: "legacy_coffee",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
});
|
||||
|
||||
expect(findTipCoffeePlan([ambiguousPlan], "medium")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats USD prices with the product-style label", () => {
|
||||
expect(
|
||||
getGiftImageSources(
|
||||
GiftProductSchema.parse({ ...product, imageUrl: null }),
|
||||
GiftCategorySchema.parse({ ...category, imageUrl: null }),
|
||||
),
|
||||
).toEqual([TIP_GIFT_PLACEHOLDER_IMAGE]);
|
||||
formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }), "small"),
|
||||
).toBe("US$ 5");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["coffee_small", "/images/tip/small.jpg"],
|
||||
["coffee_medium", "/images/tip/medium.png"],
|
||||
["coffee_large", "/images/tip/large.png"],
|
||||
])("uses the %s tier image as its local fallback", (tipType, expected) => {
|
||||
const tierProduct = GiftProductSchema.parse({
|
||||
...product,
|
||||
tipType,
|
||||
imageUrl: null,
|
||||
});
|
||||
const categoryWithoutImage = GiftCategorySchema.parse({
|
||||
...category,
|
||||
imageUrl: null,
|
||||
});
|
||||
|
||||
expect(getGiftImageSources(tierProduct, categoryWithoutImage)).toEqual([
|
||||
expected,
|
||||
]);
|
||||
it("uses the selected coffee price when its plan is unavailable", () => {
|
||||
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("uses the generic placeholder for unknown gift types", () => {
|
||||
const unknownProduct = GiftProductSchema.parse({
|
||||
...product,
|
||||
tipType: "flowers",
|
||||
imageUrl: null,
|
||||
});
|
||||
|
||||
expect(getGiftImageSources(unknownProduct, null)).toEqual([
|
||||
TIP_GIFT_PLACEHOLDER_IMAGE,
|
||||
]);
|
||||
it("treats guest and notLoggedIn as non-real login states", () => {
|
||||
expect(isRealLoginStatus("guest")).toBe(false);
|
||||
expect(isRealLoginStatus("notLoggedIn")).toBe(false);
|
||||
expect(isRealLoginStatus("facebook")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/* @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,6 +2,7 @@
|
||||
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
@@ -14,17 +15,17 @@ import styles from "./tip-screen.module.css";
|
||||
const log = new Logger("TipCheckoutButton");
|
||||
|
||||
export interface TipCheckoutButtonProps {
|
||||
giftCategory: string | null;
|
||||
giftPlanId: string | null;
|
||||
coffeeType: TipCoffeeType;
|
||||
disabled?: boolean;
|
||||
isAuthLoading?: boolean;
|
||||
onOrder: () => void;
|
||||
returnPath: string;
|
||||
}
|
||||
|
||||
export function TipCheckoutButton({
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
coffeeType,
|
||||
disabled = false,
|
||||
isAuthLoading = false,
|
||||
onOrder,
|
||||
returnPath,
|
||||
}: TipCheckoutButtonProps) {
|
||||
@@ -37,12 +38,12 @@ export function TipCheckoutButton({
|
||||
payment,
|
||||
paymentDispatch,
|
||||
subscriptionType: "tip",
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
tipCoffeeType: coffeeType,
|
||||
characterSlug: character.slug,
|
||||
});
|
||||
|
||||
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
||||
const isLoading =
|
||||
isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder;
|
||||
const label = payment.isPollingOrder
|
||||
? "Processing payment..."
|
||||
: payment.isCreatingOrder
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"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,47 +1,36 @@
|
||||
import type {
|
||||
GiftCategory,
|
||||
GiftProduct,
|
||||
} from "@/data/schemas/payment";
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
import type { PaymentPlan } from "@/data/schemas/payment";
|
||||
import {
|
||||
getTipCoffeeOption,
|
||||
type TipCoffeeType,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
|
||||
export const TIP_GIFT_PLACEHOLDER_IMAGE = "/images/tip/medium.png";
|
||||
export function findTipCoffeePlan(
|
||||
plans: readonly PaymentPlan[],
|
||||
coffeeType: TipCoffeeType,
|
||||
): PaymentPlan | null {
|
||||
const option = getTipCoffeeOption(coffeeType);
|
||||
return plans.find((plan) => plan.planId === option.planId) ?? null;
|
||||
}
|
||||
|
||||
const TIP_GIFT_PLACEHOLDER_BY_TIP_TYPE: Readonly<Record<string, string>> = {
|
||||
coffee_small: "/images/tip/small.jpg",
|
||||
coffee_medium: "/images/tip/medium.png",
|
||||
coffee_large: "/images/tip/large.png",
|
||||
};
|
||||
|
||||
export function formatGiftPrice(
|
||||
amountCents: number,
|
||||
currency: string,
|
||||
export function formatTipPrice(
|
||||
plan: PaymentPlan | null,
|
||||
coffeeType: TipCoffeeType,
|
||||
): string {
|
||||
const normalizedCurrency = currency.trim().toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
}).format(amountCents / 100);
|
||||
} catch {
|
||||
const amount = (amountCents / 100).toFixed(2);
|
||||
return normalizedCurrency ? `${normalizedCurrency} ${amount}` : amount;
|
||||
}
|
||||
const option = getTipCoffeeOption(coffeeType);
|
||||
const amountCents = plan?.amountCents ?? option.amountCents;
|
||||
const currency = plan?.currency.trim().toUpperCase() || "USD";
|
||||
|
||||
const amount = amountCents / 100;
|
||||
const formattedAmount = Number.isInteger(amount)
|
||||
? String(amount)
|
||||
: amount.toFixed(2).replace(/\.?0+$/, "");
|
||||
|
||||
if (currency === "USD") return `US$ ${formattedAmount}`;
|
||||
if (currency.length > 0) return `${currency} ${formattedAmount}`;
|
||||
return formattedAmount;
|
||||
}
|
||||
|
||||
export function getGiftImageSources(
|
||||
product: GiftProduct | null,
|
||||
category: GiftCategory | null,
|
||||
): readonly string[] {
|
||||
const placeholderImage = product
|
||||
? (TIP_GIFT_PLACEHOLDER_BY_TIP_TYPE[product.tipType] ??
|
||||
TIP_GIFT_PLACEHOLDER_IMAGE)
|
||||
: TIP_GIFT_PLACEHOLDER_IMAGE;
|
||||
|
||||
return [
|
||||
product?.imageUrl,
|
||||
category?.imageUrl,
|
||||
placeholderImage,
|
||||
].filter(
|
||||
(source, index, sources): source is string =>
|
||||
Boolean(source) && sources.indexOf(source) === index,
|
||||
);
|
||||
export function isRealLoginStatus(loginStatus: LoginStatus): boolean {
|
||||
return loginStatus !== "notLoggedIn" && loginStatus !== "guest";
|
||||
}
|
||||
|
||||
+266
-347
@@ -1,45 +1,39 @@
|
||||
.shell {
|
||||
--responsive-icon-button-size: 48px;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: var(--app-viewport-height, 100dvh);
|
||||
min-height: var(--app-viewport-height, 100dvh);
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
padding:
|
||||
calc(var(--app-safe-top, 0px) + 16px)
|
||||
calc(var(--app-safe-right, 0px) + clamp(16px, 4.5vw, 24px))
|
||||
calc(var(--app-safe-bottom, 0px) + 16px)
|
||||
calc(var(--app-safe-left, 0px) + clamp(16px, 4.5vw, 24px));
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
calc(var(--app-safe-top, 0px) + clamp(18px, 4.444vw, 24px))
|
||||
calc(var(--app-safe-right, 0px) + clamp(20px, 5.556vw, 30px))
|
||||
calc(var(--app-safe-bottom, 0px) + clamp(24px, 6.667vw, 36px))
|
||||
calc(var(--app-safe-left, 0px) + clamp(20px, 5.556vw, 30px));
|
||||
overflow: hidden auto;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 12%,
|
||||
rgba(255, 181, 105, 0.34),
|
||||
transparent 32%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 86% 24%,
|
||||
rgba(255, 85, 139, 0.2),
|
||||
transparent 30%
|
||||
),
|
||||
radial-gradient(circle at 18% 12%, rgba(255, 181, 105, 0.34), transparent 32%),
|
||||
radial-gradient(circle at 86% 24%, rgba(255, 85, 139, 0.2), transparent 30%),
|
||||
linear-gradient(180deg, #fff2e8 0%, #fff8f2 46%, #ffffff 100%);
|
||||
color: #25191b;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.bgImage,
|
||||
.bgGlowOne,
|
||||
.bgGlowTwo {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bgImage {
|
||||
inset: 0 0 auto;
|
||||
height: 330px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 242, 232, 0.3), #fff2e8 96%),
|
||||
var(--tip-cover-image) center 18% / cover no-repeat;
|
||||
opacity: 0.36;
|
||||
filter: saturate(0.95) blur(0.2px);
|
||||
}
|
||||
|
||||
.bgGlowOne,
|
||||
.bgGlowTwo {
|
||||
z-index: 0;
|
||||
border-radius: 999px;
|
||||
filter: blur(10px);
|
||||
}
|
||||
@@ -60,41 +54,55 @@
|
||||
background: rgba(255, 181, 104, 0.18);
|
||||
}
|
||||
|
||||
.header,
|
||||
.hero,
|
||||
.productCard,
|
||||
.paymentMethodSlot,
|
||||
.statusMessage,
|
||||
.successCard,
|
||||
.checkoutSlot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contentFlow {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
margin-top: auto;
|
||||
.headerPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(255, 116, 151, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
color: #9b5360;
|
||||
font-size: clamp(12px, 2.963vw, 14px);
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.03em;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.headerIdentity {
|
||||
.hero {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: clamp(18px, 5.185vw, 28px);
|
||||
text-align: center;
|
||||
animation: floatIn 0.5s ease both;
|
||||
}
|
||||
|
||||
.headerAvatar {
|
||||
.avatarRing {
|
||||
display: grid;
|
||||
width: clamp(88px, 22.222vw, 112px);
|
||||
height: clamp(88px, 22.222vw, 112px);
|
||||
flex: 0 0 auto;
|
||||
margin-top: -48px;
|
||||
place-items: center;
|
||||
border: 4px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 999px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(145deg, #ffbd7c, #ff5d91);
|
||||
box-shadow:
|
||||
0 20px 44px rgba(255, 98, 133, 0.24),
|
||||
@@ -102,86 +110,69 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
max-width: 250px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #2a1a1d;
|
||||
font-family: var(--font-athelas), Georgia, serif;
|
||||
font-size: clamp(24px, 6.4vw, 30px);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.05;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.eyebrow {
|
||||
margin: 16px 0 0;
|
||||
color: #b35d63;
|
||||
font-size: clamp(12px, 3.148vw, 15px);
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.supportPrompt {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 356px);
|
||||
box-sizing: border-box;
|
||||
margin: 12px auto 0;
|
||||
padding: 0 4px;
|
||||
color: #684d51;
|
||||
font-size: clamp(15.5px, 4.1vw, 17px);
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
opacity: 0.78;
|
||||
transition: opacity 0.24s ease;
|
||||
.title {
|
||||
max-width: 320px;
|
||||
margin: 8px 0 0;
|
||||
color: #231518;
|
||||
font-size: clamp(34px, 9.259vw, 48px);
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.92;
|
||||
}
|
||||
|
||||
.supportPromptReady {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.purchaseFlow {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 13px;
|
||||
margin-top: 13px;
|
||||
.subtitle {
|
||||
max-width: 330px;
|
||||
margin: 14px 0 0;
|
||||
color: #7c6062;
|
||||
font-size: clamp(14px, 3.704vw, 18px);
|
||||
font-weight: 620;
|
||||
line-height: 1.52;
|
||||
}
|
||||
|
||||
.productCard {
|
||||
display: grid;
|
||||
flex: 0 1 auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(112px, 0.85fr) 1fr;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 13px;
|
||||
border: 1px solid rgba(118, 61, 55, 0.09);
|
||||
border-radius: 26px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
gap: clamp(16px, 4.444vw, 24px);
|
||||
margin-top: clamp(24px, 7.407vw, 38px);
|
||||
padding: clamp(18px, 5.185vw, 28px);
|
||||
border: 1px solid rgba(118, 61, 55, 0.08);
|
||||
border-radius: clamp(30px, 8.148vw, 44px);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(255, 237, 223, 0.78)),
|
||||
#ffffff;
|
||||
box-shadow:
|
||||
0 16px 36px rgba(119, 72, 67, 0.11),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(16px);
|
||||
0 26px 62px rgba(119, 72, 67, 0.13),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||
animation: floatIn 0.5s 0.08s ease both;
|
||||
}
|
||||
|
||||
.coffeeStage {
|
||||
position: relative;
|
||||
width: clamp(150px, 42vw, 184px);
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
overflow: hidden;
|
||||
border-radius: 22px;
|
||||
border-radius: clamp(24px, 6.667vw, 32px);
|
||||
background: #c59b7d;
|
||||
box-shadow:
|
||||
0 10px 24px rgba(102, 53, 39, 0.16),
|
||||
0 18px 34px rgba(102, 53, 39, 0.18),
|
||||
inset 0 0 0 1px rgba(126, 66, 57, 0.08);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.coffeeStage::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34);
|
||||
content: "";
|
||||
@@ -194,68 +185,103 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
.productCopy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.productBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 91, 142, 0.1);
|
||||
color: #b84d65;
|
||||
font-size: clamp(11px, 2.778vw, 13px);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.productName {
|
||||
margin: 15px 0 0;
|
||||
color: #231719;
|
||||
font-size: clamp(28px, 7.407vw, 40px);
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.productPrice {
|
||||
margin: 10px 0 0;
|
||||
color: #ff4f86;
|
||||
font-size: clamp(25px, 6.667vw, 36px);
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.tierSelector {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tierLegend {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
color: #6f5055;
|
||||
font-size: clamp(13px, 3.148vw, 15px);
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.tierList {
|
||||
display: grid;
|
||||
max-height: 190px;
|
||||
gap: 7px;
|
||||
padding: 2px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tierOption {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 54px;
|
||||
grid-template-columns: minmax(0, 1fr) auto 22px;
|
||||
grid-template-columns: minmax(0, 1fr) auto 28px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 9px 8px 11px;
|
||||
gap: 12px;
|
||||
min-height: 72px;
|
||||
padding: 12px 14px 12px 16px;
|
||||
border: 1px solid rgba(112, 71, 65, 0.12);
|
||||
border-radius: 17px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.tierOption[data-selected="true"] {
|
||||
border-color: var(--color-accent, #f84d96);
|
||||
background: #fff4f8;
|
||||
background:
|
||||
linear-gradient(105deg, rgba(255, 255, 255, 0.94), rgba(255, 235, 243, 0.9)),
|
||||
#ffffff;
|
||||
box-shadow:
|
||||
0 7px 17px rgba(248, 77, 150, 0.13),
|
||||
inset 2px 0 0 var(--color-accent, #f84d96);
|
||||
0 12px 28px rgba(248, 77, 150, 0.16),
|
||||
inset 3px 0 0 var(--color-accent, #f84d96);
|
||||
}
|
||||
|
||||
.tierOption[data-unavailable="true"],
|
||||
.tierSelector:disabled .tierOption {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tierOption[data-unavailable="true"] {
|
||||
filter: grayscale(0.25);
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.tierSelector:disabled .tierOption {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.tierInput {
|
||||
@@ -271,8 +297,8 @@
|
||||
}
|
||||
|
||||
.tierOption:has(.tierInput:focus-visible) {
|
||||
outline: 3px solid rgba(248, 77, 150, 0.26);
|
||||
outline-offset: 2px;
|
||||
outline: 3px solid rgba(248, 77, 150, 0.28);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.tierDetails,
|
||||
@@ -282,129 +308,126 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tierDetails {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tierName {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #2a1b1e;
|
||||
font-family: var(--font-athelas), Georgia, serif;
|
||||
font-size: clamp(15px, 4vw, 18px);
|
||||
font-size: clamp(17px, 4.259vw, 21px);
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.015em;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tierPriceBlock {
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tierPrice {
|
||||
color: #d23f79;
|
||||
font-size: clamp(15px, 3.9vw, 18px);
|
||||
font-weight: 900;
|
||||
font-size: clamp(14px, 3.519vw, 17px);
|
||||
font-weight: 950;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tierUnavailable {
|
||||
color: #8f7376;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tierCheck {
|
||||
display: grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(111, 76, 73, 0.2);
|
||||
border-radius: 999px;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.tierCheck svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.tierOption[data-selected="true"] .tierCheck {
|
||||
border-color: var(--color-accent, #f84d96);
|
||||
background: var(--color-accent, #f84d96);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 5px 14px rgba(248, 77, 150, 0.26);
|
||||
}
|
||||
|
||||
.catalogStatus {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
flex: 1 1 auto;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(112, 71, 65, 0.1);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: #76575c;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
.successCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(118, 61, 55, 0.08);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: 0 16px 38px rgba(119, 72, 67, 0.09);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.statusMessage,
|
||||
.successTitle,
|
||||
.successText {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.statusMessage {
|
||||
margin-top: 14px;
|
||||
color: #b2474f;
|
||||
font-size: 14px;
|
||||
font-weight: 720;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.catalogStatus p {
|
||||
max-width: 270px;
|
||||
margin: 0;
|
||||
.paymentMethodSlot {
|
||||
margin-top: clamp(18px, 5.185vw, 28px);
|
||||
}
|
||||
|
||||
.catalogStatus button {
|
||||
min-height: 44px;
|
||||
padding: 0 20px;
|
||||
.successCard {
|
||||
justify-content: flex-start;
|
||||
border-color: rgba(255, 84, 135, 0.16);
|
||||
background: rgba(255, 246, 249, 0.86);
|
||||
color: #ff4f86;
|
||||
}
|
||||
|
||||
.successTitle {
|
||||
color: #25191b;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.successText {
|
||||
margin-top: 2px;
|
||||
color: #8a686b;
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.successButton {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #2b1a1e;
|
||||
padding: 9px 12px;
|
||||
background: #231719;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.productSkeleton {
|
||||
min-height: 174px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.skeletonImage,
|
||||
.skeletonList 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: clamp(150px, 42vw, 184px);
|
||||
aspect-ratio: 1;
|
||||
justify-self: center;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.skeletonList {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.skeletonList span {
|
||||
display: block;
|
||||
min-height: 54px;
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
.paymentMethodSlot {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkoutSlot {
|
||||
flex: 0 0 auto;
|
||||
margin-top: clamp(18px, 5.185vw, 28px);
|
||||
}
|
||||
|
||||
.checkoutButton {
|
||||
@@ -418,12 +441,12 @@
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: clamp(17px, 4.5vw, 20px);
|
||||
font-weight: 900;
|
||||
font-size: clamp(16px, 4.074vw, 19px);
|
||||
font-weight: 940;
|
||||
letter-spacing: -0.01em;
|
||||
box-shadow:
|
||||
0 14px 30px rgba(255, 83, 137, 0.24),
|
||||
0 6px 14px rgba(37, 23, 27, 0.15);
|
||||
0 18px 40px rgba(255, 83, 137, 0.28),
|
||||
0 8px 18px rgba(37, 23, 27, 0.18);
|
||||
transition: transform 0.18s ease, filter 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -431,7 +454,7 @@
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.25);
|
||||
opacity: 0.62;
|
||||
box-shadow: 0 8px 20px rgba(77, 52, 55, 0.1);
|
||||
box-shadow: 0 10px 24px rgba(77, 52, 55, 0.12);
|
||||
}
|
||||
|
||||
.checkoutButton:not(:disabled):active {
|
||||
@@ -439,154 +462,50 @@
|
||||
}
|
||||
|
||||
.checkoutError {
|
||||
margin: 7px 0 0;
|
||||
margin: 10px 0 0;
|
||||
color: #b2474f;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
font-weight: 720;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 350px) {
|
||||
.shell {
|
||||
padding-right: calc(var(--app-safe-right, 0px) + 12px);
|
||||
padding-left: calc(var(--app-safe-left, 0px) + 12px);
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.headerIdentity {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
max-width: 200px;
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.productCard {
|
||||
gap: 9px;
|
||||
padding: 11px;
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tierOption {
|
||||
grid-template-columns: minmax(0, 1fr) auto 20px;
|
||||
gap: 6px;
|
||||
padding-right: 7px;
|
||||
padding-left: 9px;
|
||||
}
|
||||
|
||||
.tierName {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tierPrice {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.tierCheck {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.shell {
|
||||
--responsive-icon-button-size: 44px;
|
||||
|
||||
padding-top: calc(var(--app-safe-top, 0px) + 10px);
|
||||
padding-bottom: calc(var(--app-safe-bottom, 0px) + 10px);
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.purchaseFlow {
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.supportPrompt {
|
||||
margin-top: 9px;
|
||||
padding: 0 3px;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.productCard {
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.coffeeStage,
|
||||
.skeletonImage {
|
||||
width: 148px;
|
||||
.coffeeStage {
|
||||
width: min(100%, 220px);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.tierList {
|
||||
max-height: 162px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tierOption,
|
||||
.skeletonList span {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.checkoutButton {
|
||||
min-height: 54px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 600px) {
|
||||
.headerAvatar {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.supportPrompt {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.productCard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.coffeeStage,
|
||||
.skeletonImage {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.productSkeleton {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.tierList {
|
||||
max-height: 146px;
|
||||
.tierName {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.checkoutButton:not(:disabled):hover,
|
||||
.tierSelector:not(:disabled) .tierOption:hover {
|
||||
.checkoutButton:not(:disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tierSelector:not(:disabled)
|
||||
.tierOption:not([data-unavailable="true"]):hover {
|
||||
border-color: rgba(248, 77, 150, 0.42);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeletonImage,
|
||||
.skeletonList span {
|
||||
animation: none;
|
||||
@keyframes floatIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeletonSweep {
|
||||
to {
|
||||
background-position: -120% 0;
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
+210
-202
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import { Heart, Sparkles } from "lucide-react";
|
||||
|
||||
import { BackButton, CharacterAvatar } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
@@ -14,19 +16,31 @@ import {
|
||||
type PaymentAnalyticsContext,
|
||||
} from "@/lib/analytics";
|
||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
||||
import {
|
||||
buildTipCoffeePath,
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
getTipCoffeeOption,
|
||||
TIP_COFFEE_OPTIONS,
|
||||
type TipCoffeeType,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
useActiveCharacterRoutes,
|
||||
} from "@/providers/character-provider";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import { TipCheckoutButton } from "./tip-checkout-button";
|
||||
import { TipGiftProductSelector } from "./tip-gift-product-selector";
|
||||
import { TipProductImage } from "./tip-product-image";
|
||||
import { getGiftImageSources } from "./tip-screen.helpers";
|
||||
import { TipSuccessView } from "./tip-success-view";
|
||||
import { useTipSupportPrompt } from "./use-tip-support-prompt";
|
||||
import {
|
||||
TipCoffeeTierSelector,
|
||||
type TipCoffeeTierItem,
|
||||
} from "./tip-coffee-tier-selector";
|
||||
import {
|
||||
findTipCoffeePlan,
|
||||
formatTipPrice,
|
||||
isRealLoginStatus,
|
||||
} from "./tip-screen.helpers";
|
||||
import styles from "./tip-screen.module.css";
|
||||
|
||||
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
||||
@@ -35,81 +49,83 @@ const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
||||
};
|
||||
|
||||
export interface TipScreenProps {
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
coffeeType?: TipCoffeeType;
|
||||
shouldResumePendingOrder?: boolean;
|
||||
initialPayChannel?: PayChannel | null;
|
||||
}
|
||||
|
||||
export function TipScreen({
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
coffeeType = DEFAULT_TIP_COFFEE_TYPE,
|
||||
shouldResumePendingOrder = false,
|
||||
initialPayChannel = null,
|
||||
}: TipScreenProps) {
|
||||
const navigator = useAppNavigator();
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const authState = useAuthState();
|
||||
const userState = useUserState();
|
||||
const supportPrompt = useTipSupportPrompt();
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
countryCode: userState.currentUser?.countryCode,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
});
|
||||
const [selectedCoffeeType, setSelectedCoffeeType] =
|
||||
useState<TipCoffeeType>(coffeeType);
|
||||
const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
|
||||
const returnPath = buildTipCoffeePath(
|
||||
selectedCoffeeType,
|
||||
characterRoutes.tip,
|
||||
);
|
||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||
catalog: "tip",
|
||||
characterId: character.id,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||
paymentType: "tip",
|
||||
shouldResumePendingOrder,
|
||||
});
|
||||
|
||||
const selectedCategory =
|
||||
payment.giftCategories.find(
|
||||
(category) => category.category === payment.selectedGiftCategory,
|
||||
) ?? null;
|
||||
const visibleProducts = payment.selectedGiftCategory
|
||||
? payment.giftProducts.filter(
|
||||
(product) => product.category === payment.selectedGiftCategory,
|
||||
)
|
||||
: [];
|
||||
const selectedProduct =
|
||||
visibleProducts.find(
|
||||
(product) => product.planId === payment.selectedPlanId,
|
||||
) ?? null;
|
||||
const selectedPlan =
|
||||
payment.plans.find((plan) => plan.planId === payment.selectedPlanId) ??
|
||||
null;
|
||||
const returnPath = buildTipGiftPath(
|
||||
{
|
||||
category: payment.selectedGiftCategory,
|
||||
planId: payment.selectedPlanId || null,
|
||||
},
|
||||
characterRoutes.tip,
|
||||
const coffeeTiers = useMemo(
|
||||
() =>
|
||||
TIP_COFFEE_OPTIONS.map((option) => ({
|
||||
option,
|
||||
plan: findTipCoffeePlan(payment.plans, option.type),
|
||||
})),
|
||||
[payment.plans],
|
||||
);
|
||||
|
||||
usePaymentPlanAnalytics(payment.plans, TIP_ANALYTICS_CONTEXT);
|
||||
|
||||
const coffeePlan =
|
||||
coffeeTiers.find(({ option }) => option.type === selectedCoffeeType)?.plan ??
|
||||
null;
|
||||
const priceLabel = formatTipPrice(coffeePlan, selectedCoffeeType);
|
||||
const availableCoffeePlans = useMemo(
|
||||
() => coffeeTiers.flatMap(({ plan }) => (plan ? [plan] : [])),
|
||||
[coffeeTiers],
|
||||
);
|
||||
const tierItems = useMemo<readonly TipCoffeeTierItem[]>(
|
||||
() =>
|
||||
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 =
|
||||
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
|
||||
const canCreateOrder =
|
||||
selectedProduct !== null &&
|
||||
selectedPlan !== null &&
|
||||
coffeePlan !== null &&
|
||||
payment.selectedPlanId === coffeePlan.planId &&
|
||||
payment.agreed &&
|
||||
!payment.autoRenew &&
|
||||
!payment.isLoadingPlans &&
|
||||
!isPaymentBusy;
|
||||
const isSelectionDisabled =
|
||||
!["ready", "failed", "expired"].includes(payment.status) ||
|
||||
payment.isLoadingPlans ||
|
||||
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 showMissingPlan =
|
||||
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null;
|
||||
const isTierSelectionDisabled =
|
||||
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy;
|
||||
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
|
||||
|
||||
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
||||
paymentDispatch({
|
||||
@@ -127,7 +143,18 @@ export function TipScreen({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProduct || isPaymentBusy || payment.isLoadingPlans) return;
|
||||
if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) {
|
||||
return;
|
||||
}
|
||||
if (!coffeePlan) return;
|
||||
|
||||
if (payment.selectedPlanId !== coffeePlan.planId) {
|
||||
paymentDispatch({
|
||||
type: "PaymentPlanSelected",
|
||||
planId: coffeePlan.planId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (payment.autoRenew) {
|
||||
paymentDispatch({ type: "PaymentAutoRenewChanged", autoRenew: false });
|
||||
@@ -138,70 +165,62 @@ export function TipScreen({
|
||||
paymentDispatch({ type: "PaymentAgreementChanged", agreed: true });
|
||||
}
|
||||
}, [
|
||||
isPaymentBusy,
|
||||
coffeePlan,
|
||||
payment.agreed,
|
||||
payment.autoRenew,
|
||||
payment.isCreatingOrder,
|
||||
payment.isLoadingPlans,
|
||||
payment.isPollingOrder,
|
||||
payment.selectedPlanId,
|
||||
paymentDispatch,
|
||||
selectedProduct,
|
||||
]);
|
||||
|
||||
const handleOrder = () => {
|
||||
if (!canCreateOrder || !selectedPlan) return;
|
||||
if (isAuthLoading) return;
|
||||
if (coffeePlan) {
|
||||
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
|
||||
}
|
||||
if (!isRealLoginStatus(authState.loginStatus)) {
|
||||
navigator.openAuth(returnPath);
|
||||
return;
|
||||
}
|
||||
if (!canCreateOrder) return;
|
||||
|
||||
behaviorAnalytics.planClick(selectedPlan, TIP_ANALYTICS_CONTEXT);
|
||||
paymentDispatch({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
recipientCharacterId: character.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleProductChange = (planId: string) => {
|
||||
if (isSelectionDisabled || planId === payment.selectedPlanId) return;
|
||||
if (!visibleProducts.some((product) => product.planId === planId)) return;
|
||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
||||
const handleCoffeeTypeChange = (type: TipCoffeeType) => {
|
||||
if (isTierSelectionDisabled) return;
|
||||
const nextPlan = findTipCoffeePlan(payment.plans, type);
|
||||
if (!nextPlan) return;
|
||||
|
||||
setSelectedCoffeeType(type);
|
||||
if (payment.selectedPlanId !== nextPlan.planId) {
|
||||
paymentDispatch({
|
||||
type: "PaymentPlanSelected",
|
||||
planId: nextPlan.planId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<TipSuccessView
|
||||
characterName={character.displayName}
|
||||
characterAvatar={character.assets.avatar}
|
||||
characterCover={character.assets.cover}
|
||||
giftImageSources={getGiftImageSources(
|
||||
successProduct,
|
||||
successCategory,
|
||||
)}
|
||||
giftName={
|
||||
payment.tipMessage?.productName ??
|
||||
successProduct?.planName ??
|
||||
"Your gift"
|
||||
}
|
||||
isMessageLoading={payment.isLoadingTipMessage}
|
||||
tipCount={payment.tipMessage?.tipCount ?? null}
|
||||
message={payment.tipMessage?.message ?? null}
|
||||
messageError={payment.tipMessageError}
|
||||
splashHref={characterRoutes.splash}
|
||||
onRetryMessage={() =>
|
||||
paymentDispatch({ type: "PaymentTipMessageRetryRequested" })
|
||||
}
|
||||
onSendAgain={() => paymentDispatch({ type: "PaymentReset" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const handleResetPaidState = () => {
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileShell background="#fff5ed">
|
||||
<main className={styles.shell}>
|
||||
<main
|
||||
className={styles.shell}
|
||||
style={
|
||||
{
|
||||
"--tip-cover-image": `url("${character.assets.cover}")`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={styles.bgImage} aria-hidden="true" />
|
||||
<div className={styles.bgGlowOne} aria-hidden="true" />
|
||||
<div className={styles.bgGlowTwo} aria-hidden="true" />
|
||||
|
||||
@@ -209,119 +228,108 @@ export function TipScreen({
|
||||
<BackButton
|
||||
href={characterRoutes.splash}
|
||||
variant="soft"
|
||||
iconSize={28}
|
||||
ariaLabel="Back to character home"
|
||||
analyticsKey="tip.back_to_splash"
|
||||
/>
|
||||
<span className={styles.headerPill}>{character.copy.tipHeader}</span>
|
||||
</header>
|
||||
|
||||
<div className={styles.contentFlow}>
|
||||
<div className={styles.headerIdentity}>
|
||||
<div className={styles.headerAvatar}>
|
||||
<CharacterAvatar
|
||||
src={character.assets.avatar}
|
||||
alt={character.displayName}
|
||||
size="100%"
|
||||
imageSize={88}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 id="tip-title" className={styles.headerTitle}>
|
||||
{character.copy.tipTitle}
|
||||
</h1>
|
||||
<section className={styles.hero} aria-labelledby="tip-title">
|
||||
<div className={styles.avatarRing}>
|
||||
<CharacterAvatar
|
||||
src={character.assets.avatar}
|
||||
alt={character.displayName}
|
||||
size="100%"
|
||||
imageSize={88}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`${styles.supportPrompt} ${
|
||||
supportPrompt.isReady ? styles.supportPromptReady : ""
|
||||
}`}
|
||||
>
|
||||
{supportPrompt.prompt}
|
||||
<p className={styles.eyebrow}>A little sweetness for today</p>
|
||||
<h1 id="tip-title" className={styles.title}>
|
||||
{character.copy.tipTitle}
|
||||
</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Send a warm coffee tip and keep the private moments glowing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className={styles.purchaseFlow}>
|
||||
{payment.isLoadingPlans ? (
|
||||
<section
|
||||
className={`${styles.productCard} ${styles.productSkeleton}`}
|
||||
aria-label="Loading gifts"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className={styles.skeletonImage} />
|
||||
<div className={styles.skeletonList}>
|
||||
<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>
|
||||
|
||||
<TipGiftProductSelector
|
||||
disabled={isSelectionDisabled}
|
||||
products={visibleProducts}
|
||||
onChange={handleProductChange}
|
||||
selectedPlanId={payment.selectedPlanId}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={paymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
density="compact"
|
||||
disabled={isPaymentBusy}
|
||||
className={styles.paymentMethodSlot}
|
||||
analyticsKey="tip.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
/>
|
||||
|
||||
<div className={styles.checkoutSlot}>
|
||||
<TipCheckoutButton
|
||||
giftCategory={payment.selectedGiftCategory}
|
||||
giftPlanId={payment.selectedPlanId || null}
|
||||
disabled={!canCreateOrder}
|
||||
onOrder={handleOrder}
|
||||
returnPath={returnPath}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : showCatalogError || showEmptyCatalog ? (
|
||||
<section className={styles.catalogStatus} role="status">
|
||||
<p>
|
||||
{showCatalogError
|
||||
? "We could not load gifts for this character."
|
||||
: "This character does not have any gifts available yet."}
|
||||
</p>
|
||||
{showCatalogError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
paymentDispatch({
|
||||
type: "PaymentCatalogRetryRequested",
|
||||
})
|
||||
}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
<section className={styles.productCard} aria-label="Coffee tip product">
|
||||
<div className={styles.coffeeStage}>
|
||||
<Image
|
||||
src={coffeeOption.image.src}
|
||||
alt={`${coffeeOption.displayName} coffee`}
|
||||
width={coffeeOption.image.width}
|
||||
height={coffeeOption.image.height}
|
||||
sizes="(max-width: 380px) 220px, 211px"
|
||||
className={styles.coffeeImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.productCopy}>
|
||||
<span className={styles.productBadge}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Coffee Gift
|
||||
</span>
|
||||
<h2 className={styles.productName}>
|
||||
{coffeeOption.displayName}
|
||||
</h2>
|
||||
<p className={styles.productPrice}>{priceLabel}</p>
|
||||
</div>
|
||||
|
||||
<TipCoffeeTierSelector
|
||||
disabled={isTierSelectionDisabled}
|
||||
items={tierItems}
|
||||
onChange={handleCoffeeTypeChange}
|
||||
selectedType={selectedCoffeeType}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={paymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
disabled={isPaymentBusy}
|
||||
caption="GCash by default in the Philippines"
|
||||
className={styles.paymentMethodSlot}
|
||||
analyticsKey="tip.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
/>
|
||||
|
||||
{showMissingPlan ? (
|
||||
<p className={styles.statusMessage} role="alert">
|
||||
Coffee tip is not available yet. Please try again later.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{payment.isPaid ? (
|
||||
<section className={styles.successCard} aria-live="polite">
|
||||
<Heart size={18} aria-hidden="true" />
|
||||
<div>
|
||||
<p className={styles.successTitle}>Coffee sent</p>
|
||||
<p className={styles.successText}>
|
||||
Thank you. Your payment has been confirmed.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.successButton}
|
||||
onClick={handleResetPaidState}
|
||||
>
|
||||
Send again
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className={styles.checkoutSlot}>
|
||||
<TipCheckoutButton
|
||||
coffeeType={selectedCoffeeType}
|
||||
disabled={
|
||||
showMissingPlan ||
|
||||
(!canCreateOrder && isRealLoginStatus(authState.loginStatus))
|
||||
}
|
||||
isAuthLoading={isAuthLoading}
|
||||
onOrder={handleOrder}
|
||||
returnPath={returnPath}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</MobileShell>
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
.shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: var(--app-viewport-height, 100dvh);
|
||||
place-items: center;
|
||||
padding:
|
||||
calc(var(--app-safe-top, 0px) + clamp(24px, 7vw, 40px))
|
||||
calc(var(--app-safe-right, 0px) + clamp(18px, 5.5vw, 30px))
|
||||
calc(var(--app-safe-bottom, 0px) + clamp(24px, 7vw, 40px))
|
||||
calc(var(--app-safe-left, 0px) + clamp(18px, 5.5vw, 30px));
|
||||
overflow: hidden auto;
|
||||
background:
|
||||
radial-gradient(circle at 15% 18%, rgba(255, 183, 120, 0.28), transparent 30%),
|
||||
radial-gradient(circle at 87% 77%, rgba(244, 111, 151, 0.17), transparent 31%),
|
||||
#fff7f0;
|
||||
color: #2b1a1e;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.coverWash,
|
||||
.glowOne,
|
||||
.glowTwo {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.coverWash {
|
||||
inset: 0 0 auto;
|
||||
z-index: -3;
|
||||
height: min(47vh, 420px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 247, 240, 0.48), #fff7f0 96%),
|
||||
var(--tip-success-cover) center 16% / cover no-repeat;
|
||||
filter: saturate(0.82);
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.glowOne,
|
||||
.glowTwo {
|
||||
z-index: -2;
|
||||
border-radius: 999px;
|
||||
filter: blur(18px);
|
||||
}
|
||||
|
||||
.glowOne {
|
||||
top: 16%;
|
||||
right: -90px;
|
||||
width: 210px;
|
||||
height: 210px;
|
||||
background: rgba(255, 126, 157, 0.17);
|
||||
}
|
||||
|
||||
.glowTwo {
|
||||
bottom: 8%;
|
||||
left: -110px;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: rgba(255, 188, 119, 0.2);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(100%, 420px);
|
||||
box-sizing: border-box;
|
||||
padding: clamp(30px, 8vw, 44px) clamp(22px, 7vw, 36px) clamp(24px, 7vw, 36px);
|
||||
border: 1px solid rgba(88, 50, 55, 0.08);
|
||||
border-radius: clamp(28px, 8vw, 36px);
|
||||
background: rgba(255, 255, 255, 0.93);
|
||||
box-shadow: 0 28px 74px rgba(101, 61, 61, 0.14);
|
||||
text-align: center;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.visual {
|
||||
position: relative;
|
||||
width: 164px;
|
||||
height: 140px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.avatarFrame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 13px;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.98);
|
||||
border-radius: 999px;
|
||||
background: #f4d4c4;
|
||||
box-shadow: 0 17px 36px rgba(102, 54, 57, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.coffeeFrame {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 1px;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border: 4px solid #ffffff;
|
||||
border-radius: 24px;
|
||||
background: #c69c80;
|
||||
box-shadow: 0 13px 28px rgba(86, 47, 42, 0.2);
|
||||
overflow: hidden;
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
|
||||
.coffeeImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sparkleOne,
|
||||
.sparkleTwo {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ef4f87;
|
||||
}
|
||||
|
||||
.sparkleOne {
|
||||
top: 5px;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.sparkleTwo {
|
||||
bottom: 7px;
|
||||
left: 0;
|
||||
color: #f78a68;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0 13px;
|
||||
border-radius: 999px;
|
||||
background: #fff0f4;
|
||||
color: #c74470;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.055em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 340px;
|
||||
margin: 18px auto 0;
|
||||
color: #2b1a1e;
|
||||
font-family: var(--font-athelas), Georgia, serif;
|
||||
font-size: clamp(30px, 8vw, 42px);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.04;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 330px;
|
||||
margin: 16px auto 0;
|
||||
color: #756065;
|
||||
font-size: clamp(14px, 3.7vw, 17px);
|
||||
font-weight: 610;
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.copy p {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: clamp(24px, 7vw, 34px);
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
display: inline-flex;
|
||||
min-height: 54px;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
border: 0;
|
||||
background: #2b1a1e;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 14px 30px rgba(58, 29, 35, 0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
border: 1px solid rgba(67, 40, 45, 0.1);
|
||||
background: #faf6f4;
|
||||
color: #594349;
|
||||
}
|
||||
|
||||
.primaryAction:focus-visible,
|
||||
.secondaryAction:focus-visible {
|
||||
outline: 3px solid rgba(239, 79, 135, 0.28);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.primaryAction:hover,
|
||||
.secondaryAction:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primaryAction:hover {
|
||||
box-shadow: 0 17px 34px rgba(58, 29, 35, 0.24);
|
||||
}
|
||||
|
||||
.secondaryAction:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.primaryAction:active,
|
||||
.secondaryAction:active {
|
||||
transform: translateY(1px) scale(0.99);
|
||||
}
|
||||
|
||||
@media (max-width: 350px) {
|
||||
.shell {
|
||||
padding-right: calc(var(--app-safe-right, 0px) + 14px);
|
||||
padding-left: calc(var(--app-safe-left, 0px) + 14px);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.card {
|
||||
animation: cardReveal 0.46s cubic-bezier(0.22, 0.8, 0.32, 1) both;
|
||||
}
|
||||
|
||||
.avatarFrame {
|
||||
animation: avatarReveal 0.52s 0.08s cubic-bezier(0.2, 0.9, 0.28, 1.15) both;
|
||||
}
|
||||
|
||||
.coffeeFrame {
|
||||
animation: coffeeReveal 0.54s 0.16s cubic-bezier(0.2, 0.9, 0.28, 1.15) both;
|
||||
}
|
||||
|
||||
.sparkleOne,
|
||||
.sparkleTwo {
|
||||
animation: sparkleReveal 0.5s 0.34s ease both;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes avatarReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.88);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes coffeeReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(9px, 8px) rotate(8deg) scale(0.76);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(3deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sparkleReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) rotate(-10deg);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0);
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import Link from "next/link";
|
||||
import { Gift, Heart, Sparkles } from "lucide-react";
|
||||
|
||||
import { CharacterAvatar } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
|
||||
import { TipProductImage } from "./tip-product-image";
|
||||
import styles from "./tip-success-view.module.css";
|
||||
|
||||
const GENERIC_TIP_SUCCESS_MESSAGE =
|
||||
"Thank you. Your gift made me smile.";
|
||||
const FIRST_TIP_TITLE = "Did you really just buy me a coffee?";
|
||||
const FIRST_TIP_MESSAGE = [
|
||||
"That honestly made me smile.",
|
||||
"I'll definitely think of you while I enjoy it.",
|
||||
] as const;
|
||||
|
||||
export interface TipSuccessViewProps {
|
||||
characterName: string;
|
||||
characterAvatar: string;
|
||||
characterCover: string;
|
||||
tipCount: number | null;
|
||||
giftImageSources: readonly string[];
|
||||
giftName: string;
|
||||
isMessageLoading: boolean;
|
||||
message: string | null;
|
||||
messageError: string | null;
|
||||
splashHref: string;
|
||||
onRetryMessage: () => void;
|
||||
onSendAgain: () => void;
|
||||
}
|
||||
|
||||
export function TipSuccessView({
|
||||
characterName,
|
||||
characterAvatar,
|
||||
characterCover,
|
||||
tipCount,
|
||||
giftImageSources,
|
||||
giftName,
|
||||
isMessageLoading,
|
||||
message,
|
||||
messageError,
|
||||
splashHref,
|
||||
onRetryMessage,
|
||||
onSendAgain,
|
||||
}: TipSuccessViewProps) {
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const isFirstTip = tipCount === 1;
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MobileShell background="#fff7f0">
|
||||
<main
|
||||
className={styles.shell}
|
||||
style={
|
||||
{
|
||||
"--tip-success-cover": `url("${characterCover}")`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={styles.coverWash} aria-hidden="true" />
|
||||
<div className={styles.glowOne} aria-hidden="true" />
|
||||
<div className={styles.glowTwo} aria-hidden="true" />
|
||||
|
||||
<section
|
||||
className={styles.card}
|
||||
aria-live="polite"
|
||||
aria-labelledby="tip-success-title"
|
||||
>
|
||||
<div className={styles.visual}>
|
||||
<span className={styles.sparkleOne} aria-hidden="true">
|
||||
<Sparkles size={22} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className={styles.sparkleTwo} aria-hidden="true">
|
||||
<Heart size={17} fill="currentColor" strokeWidth={1.8} />
|
||||
</span>
|
||||
<div className={styles.avatarFrame}>
|
||||
<CharacterAvatar
|
||||
src={characterAvatar}
|
||||
alt={characterName}
|
||||
size="100%"
|
||||
imageSize={112}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.coffeeFrame}>
|
||||
<TipProductImage
|
||||
key={giftName}
|
||||
sources={giftImageSources}
|
||||
alt={giftName}
|
||||
className={styles.coffeeImage}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.eyebrow}>
|
||||
<Gift size={15} aria-hidden="true" />
|
||||
Gift received
|
||||
</p>
|
||||
<h1
|
||||
ref={titleRef}
|
||||
id="tip-success-title"
|
||||
className={styles.title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{isFirstTip ? FIRST_TIP_TITLE : GENERIC_TIP_SUCCESS_MESSAGE}
|
||||
</h1>
|
||||
<div className={styles.copy}>
|
||||
{isFirstTip
|
||||
? FIRST_TIP_MESSAGE.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))
|
||||
: (
|
||||
<p>
|
||||
{message ??
|
||||
(isMessageLoading
|
||||
? "Your gift arrived. A thank-you note is on its way."
|
||||
: GENERIC_TIP_SUCCESS_MESSAGE)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{messageError ? (
|
||||
<div className={styles.messageRetry}>
|
||||
<p>We could not load the personal thank-you note.</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryAction}
|
||||
disabled={isMessageLoading}
|
||||
onClick={onRetryMessage}
|
||||
>
|
||||
{isMessageLoading ? "Retrying..." : "Retry message"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryAction}
|
||||
data-analytics-key="tip.send_again"
|
||||
onClick={onSendAgain}
|
||||
>
|
||||
Send another gift
|
||||
</button>
|
||||
<Link
|
||||
href={splashHref}
|
||||
className={styles.secondaryAction}
|
||||
data-analytics-key="tip.success_back_to_splash"
|
||||
>
|
||||
Back to {characterName}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
"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,73 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaymentRepository } from "@/data/repositories/payment_repository";
|
||||
import {
|
||||
GiftProductsResponseSchema,
|
||||
TipMessageResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
import { TipPaymentPlansResponseSchema } from "@/data/schemas/payment";
|
||||
import type { PaymentApi } from "@/data/services/api";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
describe("PaymentRepository", () => {
|
||||
it("loads a character gift catalog without adapting product metadata", async () => {
|
||||
const catalog = GiftProductsResponseSchema.parse({
|
||||
characterId: "elio",
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
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);
|
||||
it("adapts tip plans without caching them as subscription plans", async () => {
|
||||
const getTipPlans = vi.fn().mockResolvedValue(
|
||||
TipPaymentPlansResponseSchema.parse({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Medium Coffee",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const repository = new PaymentRepository({
|
||||
getGiftProducts,
|
||||
getTipPlans,
|
||||
} as unknown as PaymentApi);
|
||||
|
||||
const result = await repository.getGiftProducts("elio");
|
||||
const result = await repository.getTipPlans();
|
||||
|
||||
expect(getGiftProducts).toHaveBeenCalledWith("elio");
|
||||
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",
|
||||
expect(getTipPlans).toHaveBeenCalledOnce();
|
||||
expect(Result.isOk(result) && result.data.plans[0]).toMatchObject({
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
productName: "Golden Reserve",
|
||||
tipCount: 1,
|
||||
poolIndex: 1,
|
||||
message: "Thank you.",
|
||||
planName: "Medium Coffee",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
/**
|
||||
* 批量覆盖写入:先清空本地存储,再写入新列表。
|
||||
* 用于网络历史同步后的整批刷新。
|
||||
* 保留 Dart 端的「先清后写」语义(用于同步场景的整批刷新)。
|
||||
*/
|
||||
async saveMessagesToLocal(
|
||||
messages: readonly ChatMessage[],
|
||||
@@ -126,7 +126,7 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
/**
|
||||
* 获取本地消息数量。
|
||||
* Dexie 查询是异步操作,因此始终返回 Promise。
|
||||
* 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。
|
||||
*/
|
||||
async getLocalMessageCount(cacheIdentity?: string): Promise<Result<number>> {
|
||||
return this.localMessages.getMessageCount(cacheIdentity);
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
*/
|
||||
import type {
|
||||
CreatePaymentOrderResponse,
|
||||
GiftProductsResponse,
|
||||
PayChannel,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentPlansResponse,
|
||||
TipMessageResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
@@ -18,8 +16,8 @@ export interface IPaymentRepository {
|
||||
/** 获取本地缓存套餐列表。 */
|
||||
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
|
||||
|
||||
/** 获取当前角色的完整礼物目录。 */
|
||||
getGiftProducts(characterId: string): Promise<Result<GiftProductsResponse>>;
|
||||
/** 获取咖啡打赏套餐列表。 */
|
||||
getTipPlans(): Promise<Result<PaymentPlansResponse>>;
|
||||
|
||||
/** 清除本地缓存套餐列表。 */
|
||||
clearCachedPlans(): Promise<Result<void>>;
|
||||
@@ -34,7 +32,4 @@ export interface IPaymentRepository {
|
||||
|
||||
/** 查询支付订单状态。 */
|
||||
getOrderStatus(orderId: string): Promise<Result<PaymentOrderStatusResponse>>;
|
||||
|
||||
/** 获取已支付礼物订单的角色感谢文案。 */
|
||||
getTipMessage(orderId: string): Promise<Result<TipMessageResponse>>;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
/**
|
||||
* PaymentRepository
|
||||
*
|
||||
* 统一封装默认套餐、Tip 套餐、订单创建和订单状态查询。
|
||||
* 支付 / 付费墙相关远程调用。
|
||||
*/
|
||||
import type { IPaymentRepository } from "@/data/repositories/interfaces";
|
||||
import {
|
||||
CreatePaymentOrderRequestSchema,
|
||||
CreatePaymentOrderResponse,
|
||||
GiftProductsResponse,
|
||||
PayChannel,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansResponseSchema,
|
||||
TipMessageRequestSchema,
|
||||
TipMessageResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
import { PaymentApi, paymentApi } from "@/data/services/api";
|
||||
import { PaymentPlansStorage } from "@/data/storage/payment";
|
||||
@@ -41,11 +38,25 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取当前角色的完整礼物目录,不写入订阅套餐缓存。 */
|
||||
async getGiftProducts(
|
||||
characterId: string,
|
||||
): Promise<Result<GiftProductsResponse>> {
|
||||
return Result.wrap(() => this.api.getGiftProducts(characterId));
|
||||
/** 获取咖啡打赏套餐列表,不写入通用套餐缓存。 */
|
||||
async getTipPlans(): Promise<Result<PaymentPlansResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.getTipPlans();
|
||||
return PaymentPlansResponseSchema.parse({
|
||||
plans: response.plans.map((plan) => ({
|
||||
...plan,
|
||||
orderType: "tip",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
creditBalance: 0,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: null,
|
||||
promotionType: null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 清除本地缓存套餐列表。 */
|
||||
@@ -78,12 +89,6 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
): Promise<Result<PaymentOrderStatusResponse>> {
|
||||
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,45 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
GiftProductsResponseSchema,
|
||||
PaymentOrderStatusResponseSchema,
|
||||
PaymentPlanSchema,
|
||||
PaymentPlansResponseSchema,
|
||||
TipMessageResponseSchema,
|
||||
TipPaymentPlansResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
describe("PaymentOrderStatusResponse", () => {
|
||||
it("parses the paid gift order shape", () => {
|
||||
const response = PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "tip_order_123",
|
||||
status: "paid",
|
||||
orderType: "tip",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
creditsAdded: 0,
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
orderId: "tip_order_123",
|
||||
status: "paid",
|
||||
orderType: "tip",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
creditsAdded: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts expired status and a nullable plan id", () => {
|
||||
expect(
|
||||
PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "pay_order_456",
|
||||
status: "expired",
|
||||
orderType: "tip",
|
||||
planId: null,
|
||||
creditsAdded: 0,
|
||||
}),
|
||||
).toMatchObject({ status: "expired", planId: null, creditsAdded: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentPlan", () => {
|
||||
it("parses the camelCase payment plan shape", () => {
|
||||
const plan = PaymentPlanSchema.parse({
|
||||
@@ -192,58 +158,34 @@ describe("PaymentPlansResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GiftProductsResponse", () => {
|
||||
it("parses and freezes categories and complete gift products", () => {
|
||||
const response = GiftProductsResponseSchema.parse({
|
||||
characterId: "elio",
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
describe("TipPaymentPlansResponse", () => {
|
||||
it("keeps only fields used by the tip payment flow", () => {
|
||||
const response = TipPaymentPlansResponseSchema.parse({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Velvet Espresso",
|
||||
planName: "Small Coffee",
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.categories[0]?.category).toBe("coffee");
|
||||
expect(response.plans[0]?.description).toBe("Buy Elio a small coffee");
|
||||
expect(Object.isFrozen(response)).toBe(true);
|
||||
expect(Object.isFrozen(response.categories)).toBe(true);
|
||||
expect(Object.isFrozen(response.plans[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.message).toBe("You have a knack for making me smile.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -1,26 +0,0 @@
|
||||
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,12 +3,9 @@
|
||||
*/
|
||||
|
||||
export * from "./payment_plan";
|
||||
export * from "./gift_category";
|
||||
export * from "./gift_product";
|
||||
export * from "./tip_payment_plan";
|
||||
export * from "./request/create_payment_order_request";
|
||||
export * from "./request/tip_message_request";
|
||||
export * from "./response/create_payment_order_response";
|
||||
export * from "./response/gift_products_response";
|
||||
export * from "./response/payment_order_status_response";
|
||||
export * from "./response/payment_plans_response";
|
||||
export * from "./response/tip_message_response";
|
||||
export * from "./response/tip_payment_plans_response";
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
*/
|
||||
|
||||
export * from "./create_payment_order_request";
|
||||
export * from "./tip_message_request";
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
@@ -1,21 +0,0 @@
|
||||
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,7 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from "./create_payment_order_response";
|
||||
export * from "./gift_products_response";
|
||||
export * from "./payment_order_status_response";
|
||||
export * from "./payment_plans_response";
|
||||
export * from "./tip_message_response";
|
||||
export * from "./tip_payment_plans_response";
|
||||
|
||||
@@ -3,20 +3,14 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const PaymentOrderStatusSchema = z.enum([
|
||||
"pending",
|
||||
"paid",
|
||||
"failed",
|
||||
"expired",
|
||||
]);
|
||||
export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]);
|
||||
|
||||
export const PaymentOrderStatusResponseSchema = z
|
||||
.object({
|
||||
orderId: z.string(),
|
||||
status: PaymentOrderStatusSchema,
|
||||
orderType: z.string(),
|
||||
planId: z.string().nullable(),
|
||||
creditsAdded: z.number().int(),
|
||||
planId: z.string(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
@@ -0,0 +1,15 @@
|
||||
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,72 +13,39 @@ describe("PaymentApi", () => {
|
||||
httpClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("loads the complete public gift catalog for a character", async () => {
|
||||
it("loads public coffee tip plans from the dedicated endpoint", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
characterId: "elio",
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
planName: "Large Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_large",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "Buy Elio a large coffee",
|
||||
imageUrl: null,
|
||||
amountCents: 1999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await new PaymentApi().getGiftProducts("elio");
|
||||
const response = await new PaymentApi().getTipPlans();
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith(
|
||||
"/api/payment/gift-products",
|
||||
{ query: { characterId: "elio" } },
|
||||
);
|
||||
expect(response.categories[0]?.category).toBe("coffee");
|
||||
expect(response.plans[0]?.planId).toBe("tip_coffee_usd_19_99");
|
||||
});
|
||||
|
||||
it("posts the paid order id when loading the Tip message", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
orderId: "pay_xxx",
|
||||
characterId: "elio",
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
productName: "Large Coffee",
|
||||
tipCount: 1,
|
||||
poolIndex: 7,
|
||||
message: "Thank you for the thoughtful gift.",
|
||||
},
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans");
|
||||
expect(response).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
planName: "Large Coffee",
|
||||
amountCents: 1999,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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,8 +15,7 @@
|
||||
"paymentCreateOrder": { "method": "post", "path": "/api/payment/create-order" },
|
||||
"paymentOrderStatus": { "method": "get", "path": "/api/payment/order-status" },
|
||||
"paymentPlans": { "method": "get", "path": "/api/payment/plans" },
|
||||
"paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" },
|
||||
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
|
||||
"paymentTipPlans": { "method": "get", "path": "/api/payment/tip-plans" },
|
||||
"chatSend": { "method": "post", "path": "/api/chat/send" },
|
||||
"chatHistory": { "method": "get", "path": "/api/chat/history" },
|
||||
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
|
||||
|
||||
@@ -60,11 +60,8 @@ export class ApiPath {
|
||||
/** 获取商品套餐列表 */
|
||||
static readonly paymentPlans = apiContract.paymentPlans.path;
|
||||
|
||||
/** 获取角色的完整礼物目录 */
|
||||
static readonly paymentGiftProducts = apiContract.paymentGiftProducts.path;
|
||||
|
||||
/** 获取已支付礼物订单的角色感谢文案 */
|
||||
static readonly paymentTipMessage = apiContract.paymentTipMessage.path;
|
||||
/** 获取咖啡打赏套餐列表 */
|
||||
static readonly paymentTipPlans = apiContract.paymentTipPlans.path;
|
||||
|
||||
// ============ 聊天相关 ============
|
||||
/** 发送消息 */
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
/**
|
||||
* Payment API
|
||||
*
|
||||
* 套餐目录、订单创建与订单状态接口。
|
||||
* 付费墙 / 充值相关接口
|
||||
*/
|
||||
import {
|
||||
CreatePaymentOrderRequest,
|
||||
CreatePaymentOrderResponse,
|
||||
CreatePaymentOrderResponseSchema,
|
||||
GiftProductsResponse,
|
||||
GiftProductsResponseSchema,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentOrderStatusResponseSchema,
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansResponseSchema,
|
||||
TipMessageRequest,
|
||||
TipMessageResponse,
|
||||
TipMessageResponseSchema,
|
||||
TipPaymentPlansResponse,
|
||||
TipPaymentPlansResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
@@ -23,7 +20,9 @@ import { httpClient } from "./http_client";
|
||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export class PaymentApi {
|
||||
/** 获取 VIP 与 Top-up 套餐列表。 */
|
||||
/**
|
||||
* 获取套餐列表
|
||||
*/
|
||||
async getPlans(): Promise<PaymentPlansResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans);
|
||||
return PaymentPlansResponseSchema.parse(
|
||||
@@ -31,18 +30,17 @@ export class PaymentApi {
|
||||
);
|
||||
}
|
||||
|
||||
/** 一次获取当前角色的完整礼物目录。 */
|
||||
async getGiftProducts(characterId: string): Promise<GiftProductsResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.paymentGiftProducts,
|
||||
{ query: { characterId } },
|
||||
);
|
||||
return GiftProductsResponseSchema.parse(
|
||||
/** 获取咖啡打赏套餐列表。 */
|
||||
async getTipPlans(): Promise<TipPaymentPlansResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentTipPlans);
|
||||
return TipPaymentPlansResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建 VIP、Top-up 或 Tip 订单。 */
|
||||
/**
|
||||
* 创建支付订单
|
||||
*/
|
||||
async createOrder(
|
||||
body: CreatePaymentOrderRequest,
|
||||
): Promise<CreatePaymentOrderResponse> {
|
||||
@@ -58,7 +56,9 @@ export class PaymentApi {
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询订单最终支付状态。 */
|
||||
/**
|
||||
* 查询支付订单状态
|
||||
*/
|
||||
async getOrderStatus(orderId: string): Promise<PaymentOrderStatusResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.paymentOrderStatus,
|
||||
@@ -70,20 +70,6 @@ export class PaymentApi {
|
||||
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,18 +7,15 @@ import { SpAsyncUtil } from "@/utils/storage";
|
||||
|
||||
import { StorageKeys } from "../storage_keys";
|
||||
|
||||
const PendingPaymentOrderSchema = z
|
||||
.object({
|
||||
orderId: z.string().min(1),
|
||||
payChannel: z.literal("ezpay"),
|
||||
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
||||
giftCategory: z.string().min(1).nullable().default(null),
|
||||
giftPlanId: z.string().min(1).nullable().default(null),
|
||||
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
|
||||
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
||||
createdAt: z.number(),
|
||||
})
|
||||
.readonly();
|
||||
const PendingPaymentOrderSchema = z.object({
|
||||
orderId: z.string().min(1),
|
||||
payChannel: z.literal("ezpay"),
|
||||
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
||||
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
|
||||
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
|
||||
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export type PendingPaymentOrder = z.output<typeof PendingPaymentOrderSchema>;
|
||||
|
||||
|
||||
@@ -88,27 +88,26 @@ describe("pending payment order helpers", () => {
|
||||
await clearPendingPaymentOrder();
|
||||
});
|
||||
|
||||
it("routes tip payments back with dynamic category and plan id", () => {
|
||||
it("routes tip payments back to the tip page", () => {
|
||||
expect(
|
||||
buildPendingPaymentSubscriptionUrl({
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: "tip",
|
||||
giftCategory: "coffee",
|
||||
giftPlanId: "tip_coffee_usd_19_99",
|
||||
tipCoffeeType: "large",
|
||||
}),
|
||||
).toBe(
|
||||
"/characters/elio/tip?category=coffee&planId=tip_coffee_usd_19_99&payChannel=ezpay&paymentReturn=1",
|
||||
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets legacy tip records fall back to the first backend product", () => {
|
||||
it("defaults legacy tip payment returns to medium coffee", () => {
|
||||
expect(
|
||||
buildPendingPaymentSubscriptionUrl({
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: "tip",
|
||||
}),
|
||||
).toBe(
|
||||
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1",
|
||||
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,12 +116,11 @@ describe("pending payment order helpers", () => {
|
||||
buildPendingPaymentSubscriptionUrl({
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: "tip",
|
||||
giftCategory: "coffee",
|
||||
giftPlanId: "tip_coffee_usd_4_99",
|
||||
tipCoffeeType: "small",
|
||||
characterSlug: "nayeli",
|
||||
}),
|
||||
).toBe(
|
||||
"/characters/nayeli/tip?category=coffee&planId=tip_coffee_usd_4_99&payChannel=ezpay&paymentReturn=1",
|
||||
"/characters/nayeli/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
savePendingEzpayOrder,
|
||||
type PendingPaymentReturnTo,
|
||||
type PendingPaymentSubscriptionType,
|
||||
type PendingPaymentTipCoffeeType,
|
||||
} from "./pending_payment_order";
|
||||
|
||||
const log = new Logger("LibPaymentPaymentLaunch");
|
||||
@@ -62,8 +63,7 @@ export interface LaunchEzpayRedirectInput {
|
||||
orderId: string | null;
|
||||
paymentUrl: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
giftCategory?: string | null;
|
||||
giftPlanId?: string | null;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
characterSlug?: string;
|
||||
onOpened?: () => void;
|
||||
@@ -74,8 +74,7 @@ export async function launchEzpayRedirect({
|
||||
orderId,
|
||||
paymentUrl,
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
tipCoffeeType,
|
||||
returnTo,
|
||||
characterSlug,
|
||||
onOpened,
|
||||
@@ -102,8 +101,7 @@ export async function launchEzpayRedirect({
|
||||
const saveResult = await savePendingEzpayOrder({
|
||||
orderId,
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
...(characterSlug ? { characterSlug } : {}),
|
||||
});
|
||||
|
||||
@@ -9,18 +9,23 @@ import {
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
||||
import {
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
TIP_COFFEE_TYPE_PARAM,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export type PendingPaymentSubscriptionType =
|
||||
PendingPaymentOrder["subscriptionType"];
|
||||
export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"];
|
||||
export type PendingPaymentTipCoffeeType = NonNullable<
|
||||
PendingPaymentOrder["tipCoffeeType"]
|
||||
>;
|
||||
|
||||
export function savePendingEzpayOrder(input: {
|
||||
orderId: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
giftCategory?: string | null;
|
||||
giftPlanId?: string | null;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
characterSlug?: string;
|
||||
createdAt?: number;
|
||||
@@ -29,8 +34,7 @@ export function savePendingEzpayOrder(input: {
|
||||
orderId: input.orderId,
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: input.subscriptionType,
|
||||
giftCategory: input.giftCategory ?? null,
|
||||
giftPlanId: input.giftPlanId ?? null,
|
||||
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}),
|
||||
...(input.returnTo ? { returnTo: input.returnTo } : {}),
|
||||
...(input.characterSlug ? { characterSlug: input.characterSlug } : {}),
|
||||
createdAt: input.createdAt ?? Date.now(),
|
||||
@@ -59,24 +63,22 @@ export function buildPendingPaymentSubscriptionUrl(
|
||||
| "payChannel"
|
||||
| "returnTo"
|
||||
| "subscriptionType"
|
||||
| "tipCoffeeType"
|
||||
| "characterSlug"
|
||||
> &
|
||||
Partial<Pick<PendingPaymentOrder, "giftCategory" | "giftPlanId">>,
|
||||
>,
|
||||
): string {
|
||||
const characterSlug =
|
||||
getCharacterBySlug(order.characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||
const characterRoutes = getCharacterRoutes(characterSlug);
|
||||
|
||||
if (order.subscriptionType === "tip") {
|
||||
const path = buildTipGiftPath(
|
||||
{
|
||||
category: order.giftCategory ?? null,
|
||||
planId: order.giftPlanId ?? null,
|
||||
},
|
||||
characterRoutes.tip,
|
||||
);
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
return `${path}${separator}payChannel=${order.payChannel}&paymentReturn=1`;
|
||||
const params = new URLSearchParams({
|
||||
payChannel: order.payChannel,
|
||||
paymentReturn: "1",
|
||||
[TIP_COFFEE_TYPE_PARAM]:
|
||||
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE,
|
||||
});
|
||||
return `${characterRoutes.tip}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
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()}`;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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],
|
||||
};
|
||||
}
|
||||
@@ -72,8 +72,8 @@ describe("chat actor request cancellation", () => {
|
||||
);
|
||||
|
||||
const historyCall = historySync.syncNetworkHistory.mock.calls[0];
|
||||
expect(historyCall?.[4]).toBe("Hello from Elio");
|
||||
const signal = getSignal(historyCall?.[5]);
|
||||
expect(historyCall?.[3]).toBe("Hello from Elio");
|
||||
const signal = getSignal(historyCall?.[4]);
|
||||
expect(signal.aborted).toBe(false);
|
||||
actor.stop();
|
||||
expect(signal.aborted).toBe(true);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from "@/data/constants/character";
|
||||
import {
|
||||
ChatSendResponseSchema,
|
||||
UnlockPrivateResponseSchema,
|
||||
type ChatSendResponse,
|
||||
type ChatSendResponseInput,
|
||||
} from "@/data/schemas/chat";
|
||||
@@ -14,7 +13,6 @@ import type { ChatState } from "@/stores/chat/chat-state";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
applyNetworkHistoryLoadedOutput,
|
||||
applySingleUnlockOutput,
|
||||
countLockedHistoryMessages,
|
||||
localMessagesToUi,
|
||||
sendResponseToUiMessage,
|
||||
@@ -180,7 +178,6 @@ describe("applyHttpSendOutput", () => {
|
||||
const context = makeChatState({
|
||||
messages: [
|
||||
{
|
||||
displayId: "client:message:failed",
|
||||
content: "hello",
|
||||
isFromAI: false,
|
||||
date: "2026-06-25",
|
||||
@@ -218,7 +215,6 @@ describe("applyHttpSendOutput", () => {
|
||||
const context = makeChatState({
|
||||
messages: [
|
||||
{
|
||||
displayId: "client:message:successful",
|
||||
content: "hello",
|
||||
isFromAI: false,
|
||||
date: "2026-06-25",
|
||||
@@ -261,7 +257,6 @@ describe("chat history pagination helpers", () => {
|
||||
it("falls back to the bounded history limit when the backend limit is zero", () => {
|
||||
const nextState = applyNetworkHistoryLoadedOutput(makeChatState(), {
|
||||
messages: [],
|
||||
localDisplayIds: [],
|
||||
localCount: 0,
|
||||
total: 120,
|
||||
limit: 0,
|
||||
@@ -271,115 +266,9 @@ describe("chat history pagination helpers", () => {
|
||||
expect(nextState.nextHistoryOffset).toBe(50);
|
||||
expect(nextState.historyTotal).toBe(120);
|
||||
});
|
||||
|
||||
it("preserves messages added after the local history snapshot", () => {
|
||||
const localDisplayId = "server:history-1:assistant";
|
||||
const optimisticMessage = {
|
||||
displayId: "client:message:optimistic-1",
|
||||
clientId: "optimistic-1",
|
||||
content: "Sent while refreshing",
|
||||
isFromAI: false,
|
||||
date: "2026-07-20",
|
||||
};
|
||||
const nextState = applyNetworkHistoryLoadedOutput(
|
||||
makeChatState({
|
||||
messages: [
|
||||
{
|
||||
displayId: localDisplayId,
|
||||
remoteId: "history-1",
|
||||
content: "Cached",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
},
|
||||
optimisticMessage,
|
||||
],
|
||||
}),
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
displayId: localDisplayId,
|
||||
remoteId: "history-1",
|
||||
content: "Fresh",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
},
|
||||
],
|
||||
localDisplayIds: [localDisplayId],
|
||||
localCount: 1,
|
||||
total: 1,
|
||||
limit: 50,
|
||||
},
|
||||
);
|
||||
|
||||
expect(nextState.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
displayId: localDisplayId,
|
||||
content: "Fresh",
|
||||
}),
|
||||
optimisticMessage,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localMessagesToUi", () => {
|
||||
it("uses distinct stable display ids for user and assistant records sharing a remote id", () => {
|
||||
const records = [
|
||||
{
|
||||
id: "shared-1",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
createdAt: "2026-06-25T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "shared-1",
|
||||
role: "assistant",
|
||||
content: "Hi",
|
||||
createdAt: "2026-06-25T12:00:01.000Z",
|
||||
},
|
||||
{
|
||||
id: "shared-1",
|
||||
role: "assistant",
|
||||
content: "A duplicated backend id",
|
||||
createdAt: "2026-06-25T12:00:02.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const first = localMessagesToUi(records);
|
||||
const second = localMessagesToUi(records);
|
||||
|
||||
expect(first.map((message) => message.displayId)).toEqual([
|
||||
"server:shared-1:user",
|
||||
"server:shared-1:assistant",
|
||||
"server:shared-1:assistant:1",
|
||||
]);
|
||||
expect(first.map((message) => message.remoteId)).toEqual([
|
||||
"shared-1",
|
||||
"shared-1",
|
||||
"shared-1",
|
||||
]);
|
||||
expect(second.map((message) => message.displayId)).toEqual(
|
||||
first.map((message) => message.displayId),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a stable legacy display id when history has no remote id", () => {
|
||||
const records = [
|
||||
{
|
||||
role: "assistant",
|
||||
type: "text",
|
||||
content: "Legacy reply",
|
||||
createdAt: "2026-06-25T12:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const first = localMessagesToUi(records)[0];
|
||||
const second = localMessagesToUi(records)[0];
|
||||
|
||||
expect(first?.displayId).toMatch(/^legacy:/);
|
||||
expect(second?.displayId).toBe(first?.displayId);
|
||||
expect(first?.remoteId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps locked voice messages from history", () => {
|
||||
const [message] = localMessagesToUi([
|
||||
{
|
||||
@@ -431,53 +320,11 @@ describe("localMessagesToUi", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySingleUnlockOutput", () => {
|
||||
it("preserves display identity while adding the backend id", () => {
|
||||
const displayId = "promotion:lock-1";
|
||||
const [message] = applySingleUnlockOutput(
|
||||
[
|
||||
{
|
||||
displayId,
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
locked: true,
|
||||
lockReason: "image_paywall",
|
||||
imagePaywalled: true,
|
||||
},
|
||||
],
|
||||
{
|
||||
displayMessageId: displayId,
|
||||
request: {
|
||||
displayMessageId: displayId,
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "lock-1",
|
||||
},
|
||||
response: UnlockPrivateResponseSchema.parse({
|
||||
unlocked: true,
|
||||
messageId: "remote-1",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/unlocked.jpg",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(message).toMatchObject({
|
||||
displayId,
|
||||
remoteId: "remote-1",
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("countLockedHistoryMessages", () => {
|
||||
it("counts only unlockable locked AI messages", () => {
|
||||
expect(
|
||||
countLockedHistoryMessages([
|
||||
{
|
||||
displayId: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -485,7 +332,6 @@ describe("countLockedHistoryMessages", () => {
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
displayId: "voice-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -493,7 +339,6 @@ describe("countLockedHistoryMessages", () => {
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
{
|
||||
displayId: "image-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -503,7 +348,6 @@ describe("countLockedHistoryMessages", () => {
|
||||
lockReason: "image",
|
||||
},
|
||||
{
|
||||
displayId: "other-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -511,7 +355,6 @@ describe("countLockedHistoryMessages", () => {
|
||||
lockReason: "insufficient_credits",
|
||||
},
|
||||
{
|
||||
displayId: "user-1",
|
||||
content: "user text",
|
||||
isFromAI: false,
|
||||
date: "2026-06-29",
|
||||
|
||||
@@ -38,13 +38,13 @@ describe("chat history flow", () => {
|
||||
resolveNetwork = resolve;
|
||||
});
|
||||
const localMessage: UiMessage = {
|
||||
displayId: "local-msg",
|
||||
id: "local-msg",
|
||||
content: "cached local message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-02",
|
||||
};
|
||||
const networkMessage: UiMessage = {
|
||||
displayId: "network-msg",
|
||||
id: "network-msg",
|
||||
content: "fresh network message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-02",
|
||||
@@ -67,7 +67,6 @@ describe("chat history flow", () => {
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: {
|
||||
messages: [networkMessage],
|
||||
localDisplayIds: [localMessage.displayId],
|
||||
localOverwritten: true,
|
||||
localCount: 1,
|
||||
networkCount: 1,
|
||||
@@ -90,17 +89,17 @@ describe("chat history flow", () => {
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ displayId: "local-msg", content: "cached local message" },
|
||||
{ id: "local-msg", content: "cached local message" },
|
||||
]);
|
||||
|
||||
resolveNetwork();
|
||||
await waitFor(
|
||||
actor,
|
||||
(snapshot) => snapshot.context.messages[0]?.displayId === "network-msg",
|
||||
(snapshot) => snapshot.context.messages[0]?.id === "network-msg",
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ displayId: "network-msg", content: "fresh network message" },
|
||||
{ id: "network-msg", content: "fresh network message" },
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
@@ -109,7 +108,7 @@ describe("chat history flow", () => {
|
||||
it("loads older pages until total is exhausted and keeps existing messages", async () => {
|
||||
const requests: LoadMoreHistoryActorEvent[] = [];
|
||||
const latestMessage: UiMessage = {
|
||||
displayId: "latest",
|
||||
id: "latest",
|
||||
content: "current unlocked message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-15",
|
||||
@@ -174,8 +173,8 @@ describe("chat history flow", () => {
|
||||
);
|
||||
expect(requests[0]).toMatchObject({ offset: 50, limit: 50 });
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ displayId: "older-50" },
|
||||
{ displayId: "latest", content: "current unlocked message" },
|
||||
{ id: "older-50" },
|
||||
{ id: "latest", content: "current unlocked message" },
|
||||
]);
|
||||
|
||||
actor.send({ type: "ChatLoadMoreHistoryRequested" });
|
||||
@@ -291,10 +290,10 @@ describe("chat history flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createHistoryMessage(displayId: string): UiMessage {
|
||||
function createHistoryMessage(id: string): UiMessage {
|
||||
return {
|
||||
displayId,
|
||||
content: `Message ${displayId}`,
|
||||
id,
|
||||
content: `Message ${id}`,
|
||||
isFromAI: true,
|
||||
date: "2026-07-15",
|
||||
};
|
||||
|
||||
@@ -170,7 +170,6 @@ export function createLoadHistoryCallback(
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: {
|
||||
messages: networkMessages,
|
||||
localDisplayIds: messages.map((message) => message.displayId),
|
||||
localOverwritten: true,
|
||||
localCount: messages.length,
|
||||
networkCount: networkMessages.length,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("chat promotion", () => {
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
{
|
||||
displayId: "history-1",
|
||||
id: "history-1",
|
||||
content: "History",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
@@ -31,7 +31,7 @@ describe("chat promotion", () => {
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages.map((message) => message.displayId)).toEqual([
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
"history-1",
|
||||
"promotion:promotion-1",
|
||||
]);
|
||||
@@ -62,8 +62,7 @@ describe("chat promotion", () => {
|
||||
});
|
||||
|
||||
expect(next?.message).toMatchObject({
|
||||
displayId: "promotion:promotion-1",
|
||||
remoteId: "backend-1",
|
||||
id: "backend-1",
|
||||
imageUrl: "https://example.com/unlocked.jpg",
|
||||
imagePaywalled: false,
|
||||
locked: false,
|
||||
@@ -72,19 +71,10 @@ describe("chat promotion", () => {
|
||||
|
||||
it("keeps the promotion last and removes matching history duplicates", () => {
|
||||
const state = createChatPromotionState(promotion, "backend-1");
|
||||
const userMessage = {
|
||||
displayId: "server:backend-1:user",
|
||||
remoteId: "backend-1",
|
||||
content: "User message with the shared remote id",
|
||||
isFromAI: false,
|
||||
date: "2026-07-13",
|
||||
};
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
userMessage,
|
||||
{
|
||||
displayId: "server:backend-1:assistant",
|
||||
remoteId: "backend-1",
|
||||
id: "backend-1",
|
||||
content: "Stale history copy",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
@@ -93,6 +83,6 @@ describe("chat promotion", () => {
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages).toEqual([userMessage, state.message]);
|
||||
expect(messages).toEqual([state.message]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,10 +32,6 @@ describe("chat send flow", () => {
|
||||
{ content: "hello", isFromAI: false },
|
||||
{ content: "still there?", isFromAI: false },
|
||||
]);
|
||||
for (const message of actor.getSnapshot().context.messages) {
|
||||
expect(message.displayId).toMatch(/^client:message:/);
|
||||
expect(message.clientId).toBeTruthy();
|
||||
}
|
||||
expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(2);
|
||||
|
||||
actor.send({ type: "ChatSendMessage", content: " " });
|
||||
@@ -98,9 +94,6 @@ describe("chat send flow", () => {
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
});
|
||||
expect(
|
||||
actor.getSnapshot().context.messages.at(-1)?.displayId,
|
||||
).toMatch(/^client:image:/);
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("chat session flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "history-1",
|
||||
id: "history-1",
|
||||
content: "Existing history",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
@@ -124,7 +124,7 @@ describe("chat session flow", () => {
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.messages).toHaveLength(1);
|
||||
expect(context.promotion?.message).toMatchObject({
|
||||
displayId: "promotion:promotion-1",
|
||||
id: "promotion:promotion-1",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -28,7 +27,6 @@ describe("chat unlock flow", () => {
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
displayId: "voice-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -58,7 +56,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "msg-image-locked",
|
||||
id: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -74,7 +72,7 @@ describe("chat unlock flow", () => {
|
||||
shortfallCredits: 0,
|
||||
messages: [
|
||||
{
|
||||
displayId: "msg-image-locked",
|
||||
id: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -99,7 +97,7 @@ describe("chat unlock flow", () => {
|
||||
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false);
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
displayId: "msg-image-locked",
|
||||
id: "msg-image-locked",
|
||||
imageUrl: "https://example.com/locked.jpg",
|
||||
imagePaywalled: true,
|
||||
locked: true,
|
||||
@@ -114,7 +112,6 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -122,7 +119,6 @@ describe("chat unlock flow", () => {
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
displayId: "voice-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -136,7 +132,6 @@ describe("chat unlock flow", () => {
|
||||
shortfallCredits: 0,
|
||||
messages: [
|
||||
{
|
||||
displayId: "unlocked-1",
|
||||
content: "unlocked",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -173,7 +168,6 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -181,7 +175,6 @@ describe("chat unlock flow", () => {
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
displayId: "voice-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -218,7 +211,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "msg-private-locked",
|
||||
id: "msg-private-locked",
|
||||
content: "Original private message content.",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -245,8 +238,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: "msg-private-locked",
|
||||
remoteMessageId: "msg-private-locked",
|
||||
messageId: "msg-private-locked",
|
||||
kind: "private",
|
||||
});
|
||||
|
||||
@@ -258,7 +250,7 @@ describe("chat unlock flow", () => {
|
||||
expect(actor.getSnapshot().context.unlockingMessage).toBeNull();
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
displayId: "msg-private-locked",
|
||||
id: "msg-private-locked",
|
||||
content: "Unlocked private message content.",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
@@ -275,15 +267,13 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "server:msg-shared-id:user",
|
||||
remoteId: "msg-shared-id",
|
||||
id: "msg-shared-id",
|
||||
content: "User original question",
|
||||
isFromAI: false,
|
||||
date: "2026-06-29",
|
||||
},
|
||||
{
|
||||
displayId: "server:msg-shared-id:assistant",
|
||||
remoteId: "msg-shared-id",
|
||||
id: "msg-shared-id",
|
||||
content: "Original AI private message",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -310,8 +300,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: "server:msg-shared-id:assistant",
|
||||
remoteMessageId: "msg-shared-id",
|
||||
messageId: "msg-shared-id",
|
||||
kind: "private",
|
||||
});
|
||||
|
||||
@@ -321,14 +310,12 @@ describe("chat unlock flow", () => {
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
displayId: "server:msg-shared-id:user",
|
||||
remoteId: "msg-shared-id",
|
||||
id: "msg-shared-id",
|
||||
content: "User original question",
|
||||
isFromAI: false,
|
||||
},
|
||||
{
|
||||
displayId: "server:msg-shared-id:assistant",
|
||||
remoteId: "msg-shared-id",
|
||||
id: "msg-shared-id",
|
||||
content: "Unlocked private AI message.",
|
||||
isFromAI: true,
|
||||
locked: false,
|
||||
@@ -346,7 +333,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "msg-image-locked",
|
||||
id: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -373,8 +360,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: "msg-image-locked",
|
||||
remoteMessageId: "msg-image-locked",
|
||||
messageId: "msg-image-locked",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
@@ -384,7 +370,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
displayId: "msg-image-locked",
|
||||
id: "msg-image-locked",
|
||||
content: "",
|
||||
imageUrl: "https://example.com/locked.jpg",
|
||||
imagePaywalled: false,
|
||||
@@ -401,7 +387,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "msg-voice-locked",
|
||||
id: "msg-voice-locked",
|
||||
content: "Original voice transcript.",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -428,8 +414,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: "msg-voice-locked",
|
||||
remoteMessageId: "msg-voice-locked",
|
||||
messageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
});
|
||||
|
||||
@@ -439,7 +424,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
displayId: "msg-voice-locked",
|
||||
id: "msg-voice-locked",
|
||||
content: "Original voice transcript.",
|
||||
audioUrl: "https://example.com/unlocked-voice.mp3",
|
||||
locked: false,
|
||||
@@ -456,7 +441,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "msg-voice-locked",
|
||||
id: "msg-voice-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -488,8 +473,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: "msg-voice-locked",
|
||||
remoteMessageId: "msg-voice-locked",
|
||||
messageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
});
|
||||
|
||||
@@ -509,7 +493,7 @@ describe("chat unlock flow", () => {
|
||||
});
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
displayId: "msg-voice-locked",
|
||||
id: "msg-voice-locked",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
@@ -525,7 +509,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "msg-missing",
|
||||
id: "msg-missing",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -557,8 +541,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: "msg-missing",
|
||||
remoteMessageId: "msg-missing",
|
||||
messageId: "msg-missing",
|
||||
kind: "private",
|
||||
});
|
||||
|
||||
@@ -574,7 +557,7 @@ describe("chat unlock flow", () => {
|
||||
});
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
displayId: "msg-missing",
|
||||
id: "msg-missing",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
@@ -588,7 +571,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "msg-mismatch",
|
||||
id: "msg-mismatch",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
@@ -609,8 +592,7 @@ describe("chat unlock flow", () => {
|
||||
);
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: "msg-mismatch",
|
||||
remoteMessageId: "msg-mismatch",
|
||||
messageId: "msg-mismatch",
|
||||
kind: "voice",
|
||||
});
|
||||
await waitFor(
|
||||
@@ -635,7 +617,7 @@ describe("chat unlock flow", () => {
|
||||
resolveUnlock = resolve;
|
||||
});
|
||||
const originalMessage = {
|
||||
displayId: "promotion:lock-1",
|
||||
id: "promotion:lock-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-07-16",
|
||||
@@ -666,7 +648,7 @@ describe("chat unlock flow", () => {
|
||||
);
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
displayMessageId: "promotion:lock-1",
|
||||
messageId: "promotion:lock-1",
|
||||
remoteMessageId: "remote-1",
|
||||
kind: "image",
|
||||
lockType: "image_paywall",
|
||||
@@ -687,7 +669,6 @@ describe("chat unlock flow", () => {
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: {
|
||||
messages: [],
|
||||
localDisplayIds: [],
|
||||
localOverwritten: true,
|
||||
localCount: 0,
|
||||
networkCount: 0,
|
||||
|
||||
@@ -8,7 +8,12 @@ import { chatMachine } from "./chat-machine";
|
||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
import { appendPromotionMessage } from "./helper/promotion";
|
||||
|
||||
/** Chat UI reads this projection instead of the machine snapshot directly. */
|
||||
/**
|
||||
* 对外暴露的 State 形状
|
||||
*
|
||||
* isGuest 字段已删 —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。
|
||||
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
|
||||
*/
|
||||
interface ChatState {
|
||||
characterId: string;
|
||||
characterErrorCode: MachineContext["characterErrorCode"];
|
||||
@@ -20,7 +25,7 @@ interface ChatState {
|
||||
canSendMessage: boolean;
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: MachineContext["upgradeReason"];
|
||||
/** True as soon as a local snapshot or the first network result is renderable. */
|
||||
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
|
||||
historyLoaded: boolean;
|
||||
hasMoreHistory: boolean;
|
||||
isLoadingMoreHistory: boolean;
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
/**
|
||||
* Public events for one character-scoped Chat actor.
|
||||
* Authentication synchronization dispatches the lifecycle events; history,
|
||||
* quota, send, and unlock side effects remain owned by the machine.
|
||||
* Chat 状态机:事件联合
|
||||
*
|
||||
* 事件名与原 Dart ChatEvent 对齐。
|
||||
*
|
||||
* 历史:原本有 `ChatAuthStatusChanged`(chat 机器刷新 isGuest)—— 已删。
|
||||
* 鉴权状态变化(loginStatus)由 <ChatAuthSync /> 通过 `useAuthState()` 订阅,
|
||||
* chat 机器不感知鉴权。
|
||||
*
|
||||
* 鉴权生命周期事件(本轮新增):
|
||||
* - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏
|
||||
* - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏
|
||||
* - `ChatLogout`:正式登录用户登出 —— 清消息
|
||||
*
|
||||
* 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
|
||||
* chat-screen 不直接读 AuthStorage(除 token 取值)。
|
||||
*/
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
|
||||
export type ChatEvent =
|
||||
// Authentication lifecycle dispatched by ChatAuthSync.
|
||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||
| { type: "ChatGuestLogin" }
|
||||
| { type: "ChatUserLogin"; token: string }
|
||||
| { type: "ChatLogout" }
|
||||
@@ -28,7 +40,7 @@ export type ChatEvent =
|
||||
}
|
||||
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
|
||||
| { type: "ChatHistoryRefreshRequested" }
|
||||
// Chat domain events.
|
||||
// 业务事件
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| {
|
||||
@@ -39,7 +51,7 @@ export type ChatEvent =
|
||||
| { type: "ChatPromotionCleared" }
|
||||
| {
|
||||
type: "ChatUnlockMessageRequested";
|
||||
displayMessageId: string;
|
||||
messageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: ChatLockType;
|
||||
|
||||
@@ -15,8 +15,6 @@ const log = new Logger("StoresChatChatHistorySync");
|
||||
export type ReadAndSyncHistoryOutput = {
|
||||
/** Network-authoritative messages. Empty network history includes a UI greeting. */
|
||||
messages: UiMessage[];
|
||||
/** Display identities present in the local snapshot before the request. */
|
||||
localDisplayIds: readonly string[];
|
||||
/** True when network history was written back to local storage. */
|
||||
localOverwritten: boolean;
|
||||
localCount: number;
|
||||
@@ -33,12 +31,8 @@ export type LocalHistorySnapshotOutput = {
|
||||
|
||||
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
|
||||
|
||||
export function createGreetingMessage(
|
||||
characterId: string,
|
||||
content: string,
|
||||
): UiMessage {
|
||||
export function createGreetingMessage(content: string): UiMessage {
|
||||
return {
|
||||
displayId: `greeting:${encodeURIComponent(characterId)}`,
|
||||
content,
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
@@ -55,14 +49,10 @@ export async function resolveHistoryCacheIdentity(
|
||||
|
||||
export async function readLocalHistorySnapshot(
|
||||
cacheIdentity: string | null,
|
||||
characterId: string,
|
||||
emptyChatGreeting: string,
|
||||
): Promise<LocalHistorySnapshotOutput> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const greetingMessage = createGreetingMessage(
|
||||
characterId,
|
||||
emptyChatGreeting,
|
||||
);
|
||||
const greetingMessage = createGreetingMessage(emptyChatGreeting);
|
||||
|
||||
const localResult = cacheIdentity
|
||||
? await chatRepo.getLocalMessages(cacheIdentity)
|
||||
@@ -90,16 +80,12 @@ export async function readLocalHistorySnapshot(
|
||||
export async function syncNetworkHistory(
|
||||
characterId: string,
|
||||
localCount: number,
|
||||
localDisplayIds: readonly string[],
|
||||
cacheIdentity: string | null,
|
||||
emptyChatGreeting: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetworkHistorySyncOutput | null> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const greetingMessage = createGreetingMessage(
|
||||
characterId,
|
||||
emptyChatGreeting,
|
||||
);
|
||||
const greetingMessage = createGreetingMessage(emptyChatGreeting);
|
||||
|
||||
const networkResult = await chatRepo.getHistory(
|
||||
characterId,
|
||||
@@ -148,7 +134,6 @@ export async function syncNetworkHistory(
|
||||
|
||||
return {
|
||||
messages: finalMessages,
|
||||
localDisplayIds,
|
||||
localOverwritten,
|
||||
localCount,
|
||||
networkCount: networkUi.length,
|
||||
@@ -171,13 +156,11 @@ export async function readAndSyncHistory(
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(
|
||||
cacheIdentity,
|
||||
characterId,
|
||||
emptyChatGreeting,
|
||||
);
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
characterId,
|
||||
localSnapshot.localCount,
|
||||
localSnapshot.messages.map((message) => message.displayId),
|
||||
cacheIdentity,
|
||||
emptyChatGreeting,
|
||||
signal,
|
||||
@@ -186,9 +169,6 @@ export async function readAndSyncHistory(
|
||||
|
||||
return {
|
||||
messages: localSnapshot.messages,
|
||||
localDisplayIds: localSnapshot.messages.map(
|
||||
(message) => message.displayId,
|
||||
),
|
||||
localOverwritten: false,
|
||||
localCount: localSnapshot.localCount,
|
||||
networkCount: 0,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
|
||||
import type { ChatState } from "../chat-state";
|
||||
|
||||
// Initial and subsequent history pages use the same bounded page size.
|
||||
// The initial history request remains bounded even though pagination is disabled.
|
||||
export const CHAT_HISTORY_LIMIT = 50;
|
||||
|
||||
export function applyHistoryLoadedOutput(
|
||||
@@ -20,7 +20,6 @@ export function applyNetworkHistoryLoadedOutput(
|
||||
context: ChatState,
|
||||
output: {
|
||||
messages: UiMessage[];
|
||||
localDisplayIds: readonly string[];
|
||||
localCount: number;
|
||||
total: number;
|
||||
limit: number;
|
||||
@@ -34,15 +33,8 @@ export function applyNetworkHistoryLoadedOutput(
|
||||
| "nextHistoryOffset"
|
||||
| "isLoadingMoreHistory"
|
||||
> {
|
||||
const localDisplayIds = new Set(output.localDisplayIds);
|
||||
const networkDisplayIds = new Set(
|
||||
output.messages.map((message) => message.displayId),
|
||||
);
|
||||
const optimisticTail = context.messages.filter(
|
||||
(message) =>
|
||||
!localDisplayIds.has(message.displayId) &&
|
||||
!networkDisplayIds.has(message.displayId),
|
||||
);
|
||||
const localSnapshotSize = output.localCount === 0 ? 1 : output.localCount;
|
||||
const optimisticTail = context.messages.slice(localSnapshotSize);
|
||||
const historyLimit = normalizeHistoryLimit(output.limit);
|
||||
return {
|
||||
messages: [...output.messages, ...optimisticTail],
|
||||
@@ -103,17 +95,13 @@ export function prependUniqueHistoryMessages(
|
||||
olderMessages: readonly UiMessage[],
|
||||
): UiMessage[] {
|
||||
const existingIds = new Set(
|
||||
currentMessages.map((message) => message.displayId),
|
||||
currentMessages.flatMap((message) => (message.id ? [message.id] : [])),
|
||||
);
|
||||
const pageIds = new Set<string>();
|
||||
const uniqueOlderMessages = olderMessages.filter((message) => {
|
||||
if (
|
||||
existingIds.has(message.displayId) ||
|
||||
pageIds.has(message.displayId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
pageIds.add(message.displayId);
|
||||
if (!message.id) return true;
|
||||
if (existingIds.has(message.id) || pageIds.has(message.id)) return false;
|
||||
pageIds.add(message.id);
|
||||
return true;
|
||||
});
|
||||
return [...uniqueOlderMessages, ...currentMessages];
|
||||
|
||||
@@ -2,12 +2,7 @@ import type {
|
||||
ChatLockDetailData,
|
||||
ChatSendResponse,
|
||||
} from "@/data/schemas/chat";
|
||||
import {
|
||||
createClientUiMessageIdentity,
|
||||
createLegacyUiMessageIdentity,
|
||||
createRemoteUiMessageIdentity,
|
||||
type UiMessage,
|
||||
} from "@/stores/chat/ui-message";
|
||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
import { todayString } from "@/utils/date";
|
||||
|
||||
/**
|
||||
@@ -26,33 +21,20 @@ export function localMessagesToUi(
|
||||
lockDetail?: ChatLockDetailData;
|
||||
}[],
|
||||
): UiMessage[] {
|
||||
const occurrences = new Map<string, number>();
|
||||
return records.map((m) => {
|
||||
const isFromAI = m.role === "assistant";
|
||||
const identityBase = m.id
|
||||
? `remote:${m.id}:${m.role}`
|
||||
: `legacy:${createLegacyFingerprint(m)}`;
|
||||
const occurrence = occurrences.get(identityBase) ?? 0;
|
||||
occurrences.set(identityBase, occurrence + 1);
|
||||
const identity = m.id
|
||||
? createRemoteUiMessageIdentity(m.id, isFromAI, occurrence)
|
||||
: createLegacyUiMessageIdentity(identityBase, occurrence);
|
||||
|
||||
return {
|
||||
...identity,
|
||||
content: getAiMessageDisplayContent({
|
||||
content: m.content,
|
||||
isFromAI,
|
||||
hasImage: Boolean(m.image?.url),
|
||||
}),
|
||||
isFromAI,
|
||||
date: messageDateFromCreatedAt(m.createdAt),
|
||||
...(m.audioUrl && m.lockDetail?.locked !== true
|
||||
? { audioUrl: m.audioUrl }
|
||||
: {}),
|
||||
...deriveUiLockFields(m.lockDetail, m.image?.url),
|
||||
};
|
||||
});
|
||||
return records.map((m) => ({
|
||||
...(m.id ? { id: m.id } : {}),
|
||||
content: getAiMessageDisplayContent({
|
||||
content: m.content,
|
||||
isFromAI: m.role === "assistant",
|
||||
hasImage: Boolean(m.image?.url),
|
||||
}),
|
||||
isFromAI: m.role === "assistant",
|
||||
date: messageDateFromCreatedAt(m.createdAt),
|
||||
...(m.audioUrl && m.lockDetail?.locked !== true
|
||||
? { audioUrl: m.audioUrl }
|
||||
: {}),
|
||||
...deriveUiLockFields(m.lockDetail, m.image?.url),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,9 +43,7 @@ export function localMessagesToUi(
|
||||
*/
|
||||
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
return {
|
||||
...(response.messageId
|
||||
? createRemoteUiMessageIdentity(response.messageId, true)
|
||||
: createClientUiMessageIdentity("reply")),
|
||||
...(response.messageId ? { id: response.messageId } : {}),
|
||||
content: getAiMessageDisplayContent({
|
||||
content: response.reply,
|
||||
isFromAI: true,
|
||||
@@ -78,28 +58,6 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
};
|
||||
}
|
||||
|
||||
function createLegacyFingerprint(record: {
|
||||
type?: string;
|
||||
content: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
audioUrl?: string | null;
|
||||
image?: { type: string | null; url: string | null };
|
||||
lockDetail?: ChatLockDetailData;
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
record.role,
|
||||
record.type ?? "text",
|
||||
record.content,
|
||||
record.createdAt,
|
||||
record.audioUrl ?? null,
|
||||
record.image?.type ?? null,
|
||||
record.image?.url ?? null,
|
||||
record.lockDetail?.locked ?? null,
|
||||
record.lockDetail?.reason ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
function messageDateFromCreatedAt(createdAt: string): string {
|
||||
const parsed = new Date(createdAt);
|
||||
if (Number.isNaN(parsed.getTime())) return todayString();
|
||||
|
||||
@@ -28,8 +28,7 @@ export function createChatPromotionState(
|
||||
return {
|
||||
session,
|
||||
message: {
|
||||
displayId: `promotion:${session.clientLockId}`,
|
||||
...(messageId ? { remoteId: messageId } : {}),
|
||||
id: messageId || `promotion:${session.clientLockId}`,
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
@@ -48,13 +47,7 @@ export function appendPromotionMessage(
|
||||
): UiMessage[] {
|
||||
if (!promotion) return [...messages];
|
||||
return [
|
||||
...messages.filter(
|
||||
(message) =>
|
||||
message.displayId !== promotion.message.displayId &&
|
||||
(!promotion.message.remoteId ||
|
||||
!message.isFromAI ||
|
||||
message.remoteId !== promotion.message.remoteId),
|
||||
),
|
||||
...messages.filter((message) => message.id !== promotion.message.id),
|
||||
promotion.message,
|
||||
];
|
||||
}
|
||||
@@ -63,10 +56,7 @@ export function applyPromotionUnlockOutput(
|
||||
promotion: ChatPromotionState | null,
|
||||
output: UnlockMessageOutput,
|
||||
): ChatPromotionState | null {
|
||||
if (
|
||||
!promotion ||
|
||||
promotion.message.displayId !== output.displayMessageId
|
||||
) {
|
||||
if (!promotion || promotion.message.id !== output.displayMessageId) {
|
||||
return promotion;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -30,11 +30,11 @@ export function applySingleUnlockOutput(
|
||||
return message;
|
||||
}
|
||||
|
||||
const remoteId = output.response.messageId || message.remoteId;
|
||||
const resolvedId = output.response.messageId || message.id;
|
||||
if (!output.response.unlocked) {
|
||||
return {
|
||||
...message,
|
||||
...(remoteId ? { remoteId } : {}),
|
||||
id: resolvedId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function applySingleUnlockOutput(
|
||||
|
||||
return {
|
||||
...message,
|
||||
...(remoteId ? { remoteId } : {}),
|
||||
id: resolvedId,
|
||||
content: resolvedContent,
|
||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||
imageUrl: resolvedImageUrl,
|
||||
@@ -69,11 +69,8 @@ function getUnlockedAudioUrl(
|
||||
return response.audioUrl;
|
||||
}
|
||||
|
||||
function shouldApplySingleUnlock(
|
||||
message: UiMessage,
|
||||
displayMessageId: string,
|
||||
): boolean {
|
||||
if (message.displayId !== displayMessageId) return false;
|
||||
function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean {
|
||||
if (message.id !== messageId) return false;
|
||||
if (!message.isFromAI) return false;
|
||||
if (message.locked !== true) return false;
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,6 @@ export const loadHistoryActor = fromCallback<
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(
|
||||
cacheIdentity,
|
||||
input.characterId,
|
||||
input.emptyChatGreeting,
|
||||
);
|
||||
if (cancelled) return;
|
||||
@@ -58,7 +57,6 @@ export const loadHistoryActor = fromCallback<
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
input.characterId,
|
||||
localSnapshot.localCount,
|
||||
localSnapshot.messages.map((message) => message.displayId),
|
||||
cacheIdentity,
|
||||
input.emptyChatGreeting,
|
||||
controller.signal,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
finishPendingReply,
|
||||
type HttpSendOutput,
|
||||
} from "../helper/send-state";
|
||||
import { createClientUiMessageIdentity } from "../ui-message";
|
||||
import { historyMachineSetup } from "./history-flow";
|
||||
import { createChatActorActionSetup } from "./setup";
|
||||
|
||||
@@ -37,7 +36,6 @@ const appendGuestUserMessageAction = historyMachineSetup.assign(
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("message"),
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
@@ -65,7 +63,6 @@ const appendUserMessageAction = historyMachineSetup.assign(
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("message"),
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
@@ -86,7 +83,6 @@ const appendQueuedSendErrorMessageAction = historyMachineSetup.assign(
|
||||
const messages = [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("error"),
|
||||
content: unavailable
|
||||
? "This character is temporarily unavailable. Please try again shortly."
|
||||
: "Something went wrong. Try sending again?",
|
||||
@@ -139,7 +135,6 @@ const appendGuestUserImageAction = historyMachineSetup.assign(
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("image"),
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
@@ -167,7 +162,6 @@ const appendUserImageAction = historyMachineSetup.assign(
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("image"),
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
|
||||
@@ -157,8 +157,7 @@ const userReadyState = chatMachineSetup.createStateConfig({
|
||||
actions: "appendUserImage",
|
||||
},
|
||||
ChatUnlockMessageRequested: {
|
||||
guard: ({ event }) =>
|
||||
event.displayMessageId.trim().length > 0,
|
||||
guard: ({ event }) => event.messageId.trim().length > 0,
|
||||
target: "unlockingMessage",
|
||||
actions: "markUnlockMessageStarted",
|
||||
},
|
||||
|
||||
@@ -45,12 +45,12 @@ const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
|
||||
const markUnlockMessageStartedAction = sendMachineSetup.assign(
|
||||
({ event }) => {
|
||||
if (event.type !== "ChatUnlockMessageRequested") return {};
|
||||
const remoteMessageId =
|
||||
event.remoteMessageId ?? (event.lockType ? undefined : event.messageId);
|
||||
return {
|
||||
unlockingMessage: {
|
||||
displayMessageId: event.displayMessageId,
|
||||
...(event.remoteMessageId
|
||||
? { messageId: event.remoteMessageId }
|
||||
: {}),
|
||||
displayMessageId: event.messageId,
|
||||
...(remoteMessageId ? { messageId: remoteMessageId } : {}),
|
||||
kind: event.kind,
|
||||
...(event.lockType ? { lockType: event.lockType } : {}),
|
||||
...(event.clientLockId
|
||||
@@ -90,7 +90,8 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
|
||||
unlockMessageError: output.response.reason,
|
||||
unlockPaywallRequest: shouldOpenPaywall
|
||||
? {
|
||||
displayMessageId: output.displayMessageId,
|
||||
displayMessageId:
|
||||
output.response.messageId || output.displayMessageId,
|
||||
...(output.response.messageId || output.request.messageId
|
||||
? {
|
||||
messageId:
|
||||
@@ -104,8 +105,7 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
|
||||
...(output.request.clientLockId
|
||||
? { clientLockId: output.request.clientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.displayId ===
|
||||
output.displayMessageId
|
||||
...(context.promotion?.message.id === output.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: output.response.reason,
|
||||
@@ -130,8 +130,7 @@ const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign(
|
||||
unlockPaywallRequest: request
|
||||
? {
|
||||
...request,
|
||||
...(context.promotion?.message.displayId ===
|
||||
request.displayMessageId
|
||||
...(context.promotion?.message.id === request.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: "unlock_failed",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UiMessageSchema = z.object({
|
||||
displayId: z.string().min(1),
|
||||
remoteId: z.string().min(1).optional(),
|
||||
clientId: z.string().min(1).optional(),
|
||||
id: z.string().optional(),
|
||||
content: z.string(),
|
||||
isFromAI: z.boolean(),
|
||||
date: z.string(),
|
||||
@@ -20,41 +18,6 @@ export const UiMessageSchema = z.object({
|
||||
|
||||
export type UiMessage = z.infer<typeof UiMessageSchema>;
|
||||
|
||||
let fallbackClientId = 0;
|
||||
|
||||
export function createRemoteUiMessageIdentity(
|
||||
remoteId: string,
|
||||
isFromAI: boolean,
|
||||
occurrence = 0,
|
||||
): Pick<UiMessage, "displayId" | "remoteId"> {
|
||||
const role = isFromAI ? "assistant" : "user";
|
||||
const base = `server:${encodeURIComponent(remoteId)}:${role}`;
|
||||
return {
|
||||
displayId: occurrence > 0 ? `${base}:${occurrence}` : base,
|
||||
remoteId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLegacyUiMessageIdentity(
|
||||
fingerprint: string,
|
||||
occurrence = 0,
|
||||
): Pick<UiMessage, "displayId"> {
|
||||
const base = `legacy:${hashIdentity(fingerprint)}`;
|
||||
return {
|
||||
displayId: occurrence > 0 ? `${base}:${occurrence}` : base,
|
||||
};
|
||||
}
|
||||
|
||||
export function createClientUiMessageIdentity(
|
||||
kind: "message" | "image" | "reply" | "error",
|
||||
): Pick<UiMessage, "displayId" | "clientId"> {
|
||||
const clientId = createClientId();
|
||||
return {
|
||||
clientId,
|
||||
displayId: `client:${kind}:${clientId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const UiMessage = {
|
||||
create(input: Omit<UiMessage, "date"> & { date?: string }): UiMessage {
|
||||
return UiMessageSchema.parse({
|
||||
@@ -68,20 +31,3 @@ export const UiMessage = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function createClientId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
fallbackClientId += 1;
|
||||
return `${Date.now().toString(36)}-${fallbackClientId.toString(36)}`;
|
||||
}
|
||||
|
||||
function hashIdentity(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
|
||||
import {
|
||||
createTestPaymentMachine,
|
||||
giftCatalog,
|
||||
lifetimePlan,
|
||||
monthlyPlan,
|
||||
quarterlyPlan,
|
||||
tipPlan,
|
||||
} from "./payment-machine.test-utils";
|
||||
|
||||
describe("payment catalog flow", () => {
|
||||
@@ -31,76 +31,25 @@ describe("payment catalog flow", () => {
|
||||
});
|
||||
|
||||
it("loads the tip catalog and disables auto renew", async () => {
|
||||
const loadGiftProducts = vi.fn();
|
||||
const loadCatalog = vi.fn();
|
||||
const refreshCatalog = vi.fn();
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
onLoadGiftProducts: loadGiftProducts,
|
||||
refreshedPlans: PaymentPlansResponseSchema.parse({ plans: [tipPlan] }),
|
||||
onLoadCatalog: loadCatalog,
|
||||
onRefreshCatalog: refreshCatalog,
|
||||
}),
|
||||
).start();
|
||||
actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
|
||||
actor.send({ type: "PaymentInit", catalog: "tip" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
expect(loadGiftProducts).toHaveBeenCalledWith("elio");
|
||||
expect(loadCatalog).toHaveBeenCalledWith("tip");
|
||||
expect(refreshCatalog).toHaveBeenCalledWith("tip");
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
planCatalog: "tip",
|
||||
selectedGiftCategory: "coffee",
|
||||
selectedPlanId: "tip_coffee_usd_4_99",
|
||||
selectedPlanId: tipPlan.planId,
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,15 +4,10 @@ import { fromPromise } from "xstate";
|
||||
import {
|
||||
CreatePaymentOrderResponse,
|
||||
CreatePaymentOrderResponseSchema,
|
||||
GiftProductsResponse,
|
||||
GiftProductsResponseSchema,
|
||||
type PayChannel,
|
||||
type PaymentOrderStatus,
|
||||
PaymentOrderStatusResponseSchema,
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansResponseSchema,
|
||||
TipMessageResponse,
|
||||
TipMessageResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
import { paymentMachine } from "@/stores/payment/payment-machine";
|
||||
import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
|
||||
@@ -82,77 +77,9 @@ export const quarterlyPlan = {
|
||||
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 = {
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Velvet Espresso",
|
||||
planName: "Small Coffee",
|
||||
orderType: "tip",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
@@ -163,35 +90,17 @@ export const tipPlan = {
|
||||
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(
|
||||
overrides: Partial<{
|
||||
cachedPlans: PaymentPlansResponse | null;
|
||||
refreshedPlans: PaymentPlansResponse;
|
||||
refreshPlans: () => Promise<PaymentPlansResponse>;
|
||||
giftProducts: GiftProductsResponse;
|
||||
giftProductsError: Error;
|
||||
onLoadCatalog: (catalog: PaymentPlanCatalog) => void;
|
||||
onRefreshCatalog: (catalog: PaymentPlanCatalog) => void;
|
||||
onLoadGiftProducts: (characterId: string) => void;
|
||||
createOrderSpy: CreateOrderSpy;
|
||||
createOrderError: Error;
|
||||
orderStatus: PaymentOrderStatus;
|
||||
orderStatuses: PaymentOrderStatus[];
|
||||
orderType: string;
|
||||
orderPlanId: string | null;
|
||||
tipMessage: TipMessageResponse;
|
||||
tipMessageError: Error;
|
||||
onLoadTipMessage: (orderId: string) => void;
|
||||
orderStatus: "pending" | "paid" | "failed";
|
||||
orderStatuses: ("pending" | "paid" | "failed")[];
|
||||
}> = {},
|
||||
) {
|
||||
const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>();
|
||||
@@ -220,14 +129,6 @@ 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>(
|
||||
async ({ input }) => {
|
||||
createOrderSpy(input);
|
||||
@@ -249,18 +150,10 @@ export function createTestPaymentMachine(
|
||||
return PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "pay_test_001",
|
||||
status,
|
||||
orderType: overrides.orderType ?? "vip_monthly",
|
||||
planId: overrides.orderPlanId ?? "vip_monthly",
|
||||
creditsAdded: 0,
|
||||
orderType: "vip_monthly",
|
||||
planId: "vip_monthly",
|
||||
});
|
||||
}),
|
||||
loadTipMessage: fromPromise<TipMessageResponse, { orderId: string }>(
|
||||
async ({ input }) => {
|
||||
overrides.onLoadTipMessage?.(input.orderId);
|
||||
if (overrides.tipMessageError) throw overrides.tipMessageError;
|
||||
return overrides.tipMessage ?? defaultTipMessage;
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user