Compare commits

..

2 Commits

Author SHA1 Message Date
admin 96a2accde4 fix(splash): hide experimental splash components
Docker Image / Quality and Bundle Budgets (push) Successful in 3m33s
Docker Image / Build and Push Docker Image (push) Successful in 1m53s
2026-07-20 17:49:19 +08:00
admin 26ab790078 chore(icons): 使用生产环境的图标 2026-07-20 17:49:19 +08:00
114 changed files with 2495 additions and 5220 deletions
-403
View File
@@ -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
```
+92 -251
View File
@@ -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 和积分充值会忽略该字段
-292
View File
@@ -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 创建订单携带当前角色 IDVIP/Top-up 不携带;
9. Tip Message 成功、失败和重试不重复创建订单;
10. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。
-235
View File
@@ -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 | 打开 Authredirect 为当前角色 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 ActorTop-up 通过路由级导航进入独立 Payment Provider。
## 8. 变更验收
Private Room 协议相关变更至少验证:
1. `src/data/schemas/private-room/__tests__`
2. `src/data/services/api/__tests__/multi_character_api.test.ts` 中的 Private Room 请求;
3. `src/stores/private-room/__tests__`
4. `src/app/private-room/__tests__` 与组件测试;
5. Elio、Maya、Nayeli 列表互不串联;
6. 登录身份变化会刷新当前角色列表;
7. 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支;
8. Auth、Top-up 和支付成功后返回原角色;
9. 锁定相册不能通过 Gallery URL 绕过;
10. 切换角色后旧 Actor 的相册与解锁状态不再可见。
+1 -7
View File
@@ -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);
});
});
+6 -10
View File
@@ -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),
+23 -24
View File
@@ -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,
+14 -1
View File
@@ -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(
+17 -45
View File
@@ -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
/>,
+10 -16
View File
@@ -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 {
+3 -3
View File
@@ -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"
+7 -9
View File
@@ -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";
+10 -19
View File
@@ -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}
+14 -23
View File
@@ -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";
+36 -10
View File
@@ -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 事件 ready3s 超时)
// - 调浏览器原生 prompt
// - 返 "accepted" / "dismissed" / "unavailable"
// 当前不消费返回值 —— 用户已经看到 dialog 点了 OK,是否真装上是浏览器的事。
// 后续可在此接 metrics 上报(pwa_accepted / pwa_dismissed / pwa_unavailable)。
await pwaUtil.install();
}}
/>
+3 -3
View File
@@ -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>
);
}
+8 -7
View File
@@ -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
+70
View File
@@ -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>
);
}
-61
View File
@@ -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>
);
}
-38
View File
@@ -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);
}
}}
/>
);
}
+30 -41
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-347
View File
@@ -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);
}
}
-165
View File
@@ -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>
);
}
-60
View File
@@ -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.");
});
});
+2 -2
View File
@@ -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>>;
}
+20 -15
View File
@@ -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.");
});
});
-14
View File
@@ -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;
-26
View File
@@ -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;
+2 -5
View File
@@ -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;
+1 -2
View File
@@ -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.");
});
});
+1 -2
View File
@@ -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" },
+2 -5
View File
@@ -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;
// ============ 聊天相关 ============
/** 发送消息 */
+16 -30
View File
@@ -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",
);
});
});
+4 -6
View File
@@ -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 } : {}),
});
+18 -16
View File
@@ -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({
+58
View File
@@ -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");
});
});
-24
View File
@@ -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);
});
});
+86
View File
@@ -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()}`;
}
-31
View File
@@ -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;
}
-62
View File
@@ -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,
+7 -2
View File
@@ -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;
+18 -6
View File
@@ -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;
+3 -23
View File
@@ -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,
+7 -19
View File
@@ -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];
+16 -58
View File
@@ -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();
+3 -13
View File
@@ -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 {
+5 -8
View File
@@ -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,
-6
View File
@@ -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,
+1 -2
View File
@@ -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",
},
+8 -9
View File
@@ -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 -55
View File
@@ -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