feat(characters): use local character catalog
@@ -16,7 +16,6 @@
|
|||||||
"./src/data/schemas/auth/request",
|
"./src/data/schemas/auth/request",
|
||||||
"./src/data/schemas/auth/response",
|
"./src/data/schemas/auth/response",
|
||||||
"./src/data/schemas/chat",
|
"./src/data/schemas/chat",
|
||||||
"./src/data/schemas/character",
|
|
||||||
"./src/data/schemas/chat/request",
|
"./src/data/schemas/chat/request",
|
||||||
"./src/data/schemas/chat/response",
|
"./src/data/schemas/chat/response",
|
||||||
"./src/data/schemas/feedback",
|
"./src/data/schemas/feedback",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
迁移目标:
|
迁移目标:
|
||||||
|
|
||||||
1. 后端提供统一角色目录。
|
1. 前端维护统一的本地角色目录,后端只识别稳定角色 ID。
|
||||||
2. 同一用户可以分别与多个角色聊天。
|
2. 同一用户可以分别与多个角色聊天。
|
||||||
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
|
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
|
||||||
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
|
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
|
||||||
@@ -29,79 +29,19 @@
|
|||||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
||||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
| production 生产环境 | `https://api.banlv-ai.com` |
|
||||||
|
|
||||||
## 2. 角色目录接口
|
## 2. 前端本地角色目录
|
||||||
|
|
||||||
### 2.1 请求
|
角色列表不通过网络接口加载。前端在本地维护只读目录,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。
|
||||||
|
|
||||||
```http
|
当前角色:
|
||||||
GET <API_BASE_URL>/api/characters
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
接口支持正式用户 Login Token 和游客 Guest Token。
|
| `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` |
|
||||||
|
|
||||||
### 2.2 成功响应
|
本地记录同时保存短名称及 Splash、Private Room、Tip 使用的角色文案。后端不提供头像、封面、展示名称或角色目录接口,但后端配置中的角色 ID 必须与前端目录一致。
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "character_elio",
|
|
||||||
"slug": "elio",
|
|
||||||
"displayName": "Elio Silvestri",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "character_maya",
|
|
||||||
"slug": "maya",
|
|
||||||
"displayName": "Maya Tan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "character_nayeli",
|
|
||||||
"slug": "nayeli",
|
|
||||||
"displayName": "Nayeli Cervantes",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
公开字段只有:
|
|
||||||
|
|
||||||
| 字段 | 类型 | 是否必填 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `id` | string | 是 | 稳定、唯一、不可变的角色业务 ID。 |
|
|
||||||
| `slug` | string | 是 | 稳定、唯一、URL 安全的角色标识。 |
|
|
||||||
| `displayName` | string | 是 | 前端展示名称。 |
|
|
||||||
| `avatarUrl` | string | 是 | HTTPS 头像地址。 |
|
|
||||||
|
|
||||||
后端只返回启用角色,并按照后台运营顺序排列 `items`。前端直接使用数组顺序,不执行二次排序。
|
|
||||||
|
|
||||||
响应中不要增加以下字段:
|
|
||||||
|
|
||||||
- `enabled`
|
|
||||||
- `sortOrder`
|
|
||||||
- `coverUrl`
|
|
||||||
- `chatBackgroundUrl`
|
|
||||||
- `tagline`
|
|
||||||
- `emptyChatGreeting`
|
|
||||||
- `capabilities`
|
|
||||||
- 创建人、更新时间等后台管理字段
|
|
||||||
|
|
||||||
当前约定所有返回的角色都支持聊天、私密空间和打赏。
|
|
||||||
|
|
||||||
### 2.3 角色字段规则
|
|
||||||
|
|
||||||
- `id` 建议使用不可枚举 UUID 或稳定业务 ID,最大长度 64。
|
|
||||||
- `slug` 只允许小写字母、数字和连字符,建议校验 `^[a-z0-9]+(?:-[a-z0-9]+)*$`。
|
|
||||||
- `slug` 发布后不能修改;必须修改时应保留旧 slug 重定向映射。
|
|
||||||
- `displayName` 去除首尾空白后不能为空,建议最大长度 80。
|
|
||||||
- `avatarUrl` 必须为有效 HTTPS URL。
|
|
||||||
- `id` 和 `slug` 都需要数据库唯一约束。
|
|
||||||
|
|
||||||
角色目录不需要分页,也不返回 `total`。
|
|
||||||
|
|
||||||
## 3. 聊天接口调整
|
## 3. 聊天接口调整
|
||||||
|
|
||||||
@@ -116,7 +56,7 @@ POST /api/chat/unlock-private
|
|||||||
POST /api/chat/unlock-history
|
POST /api/chat/unlock-history
|
||||||
```
|
```
|
||||||
|
|
||||||
`characterId` 使用角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。
|
`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。
|
||||||
|
|
||||||
后端必须使用以下维度定位聊天数据:
|
后端必须使用以下维度定位聊天数据:
|
||||||
|
|
||||||
@@ -268,7 +208,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `items` 只包含当前启用角色,并保持角色目录顺序。
|
- `items` 按前端本地角色目录顺序返回当前可用角色。
|
||||||
- `message` 使用现有 ChatMessage wire 结构;没有历史时返回 `null`。
|
- `message` 使用现有 ChatMessage wire 结构;没有历史时返回 `null`。
|
||||||
- 锁消息继续遵循现有内容隐藏规则,不能通过预览接口泄露 URL 或锁定内容。
|
- 锁消息继续遵循现有内容隐藏规则,不能通过预览接口泄露 URL 或锁定内容。
|
||||||
- 查询应批量完成,避免后端内部出现按角色逐条查询的 N+1 问题。
|
- 查询应批量完成,避免后端内部出现按角色逐条查询的 N+1 问题。
|
||||||
@@ -399,7 +339,7 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归
|
|||||||
|
|
||||||
### 第一阶段:后端兼容
|
### 第一阶段:后端兼容
|
||||||
|
|
||||||
- 上线角色目录和新字段。
|
- 上线角色 ID 配置和新字段。
|
||||||
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。
|
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。
|
||||||
- Private Room 缺少角色时暂时默认 Elio。
|
- Private Room 缺少角色时暂时默认 Elio。
|
||||||
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
|
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
|
||||||
@@ -459,61 +399,22 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归
|
|||||||
|
|
||||||
后端完成后至少验证:
|
后端完成后至少验证:
|
||||||
|
|
||||||
1. `GET /api/characters` 只返回 4 个公开字段。
|
1. 前端本地目录中的每个角色 ID 均可调用 Chat、Private Room 和 Tip 业务接口。
|
||||||
2. 角色目录只包含启用角色,并严格按照后台排序。
|
2. Login Token 和 Guest Token 均可按角色 ID 聊天。
|
||||||
3. Login Token 和 Guest Token 均可获取角色目录并聊天。
|
3. 同一用户在 Elio 和其他角色中的历史完全隔离。
|
||||||
4. 同一用户在 Elio 和其他角色中的历史完全隔离。
|
4. 发送消息、历史分页、单条解锁和历史解锁都限定当前角色。
|
||||||
5. 发送消息、历史分页、单条解锁和历史解锁都限定当前角色。
|
5. 使用 Elio 的 messageId 配合其他角色 ID 解锁时返回 `CHARACTER_MISMATCH`,且不扣积分。
|
||||||
6. 使用 Elio 的 messageId 配合其他角色 ID 解锁时返回 `CHARACTER_MISMATCH`,且不扣积分。
|
6. Private Room 的标准 characterId 与旧 character slug 在兼容期结果一致。
|
||||||
7. Private Room 的标准 characterId 与旧 character slug 在兼容期结果一致。
|
7. Tip 订单正确保存 `recipient_character_id`,VIP 和 Top-up 不保存。
|
||||||
8. Tip 订单正确保存 `recipient_character_id`,VIP 和 Top-up 不保存。
|
8. 角色预览接口一次返回各启用角色的最新消息,没有历史时返回 `null`。
|
||||||
9. 角色预览接口一次返回各启用角色的最新消息,没有历史时返回 `null`。
|
9. 后端禁用角色后,直接访问业务接口返回 `CHARACTER_DISABLED`;前端发布时同步移除本地记录。
|
||||||
10. 禁用角色从目录消失,直接访问业务接口返回 `CHARACTER_DISABLED`。
|
10. 缺少角色字段的旧请求在兼容期仍进入 Elio。
|
||||||
11. 缺少角色字段的旧请求在兼容期仍进入 Elio。
|
11. 历史数据回填前后记录数量、积分和解锁状态一致。
|
||||||
12. 历史数据回填前后记录数量、积分和解锁状态一致。
|
|
||||||
|
|
||||||
## 13. OpenAPI 参考
|
## 13. OpenAPI 参考
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
paths:
|
paths:
|
||||||
/api/characters:
|
|
||||||
get:
|
|
||||||
summary: List enabled characters in display order
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Enabled characters
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [success, data]
|
|
||||||
properties:
|
|
||||||
success:
|
|
||||||
type: boolean
|
|
||||||
const: true
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
required: [items]
|
|
||||||
properties:
|
|
||||||
items:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required: [id, slug, displayName, avatarUrl]
|
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
slug:
|
|
||||||
type: string
|
|
||||||
pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
||||||
displayName:
|
|
||||||
type: string
|
|
||||||
avatarUrl:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
/api/chat/history:
|
/api/chat/history:
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ https://<APP_HOST>/external-entry?<参数>
|
|||||||
| 参数 | 可选值或格式 | 用途 |
|
| 参数 | 可选值或格式 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `target` | `chat`、`tip`、`private-room` | 指定进入的页面;不传或值无效时进入聊天页。 |
|
| `target` | `chat`、`tip`、`private-room` | 指定进入的页面;不传或值无效时进入聊天页。 |
|
||||||
|
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。 |
|
||||||
| `psid` | string | 传入 Facebook Page-scoped User ID。 |
|
| `psid` | string | 传入 Facebook Page-scoped User ID。 |
|
||||||
| `mode` | `promotion` | 开启聊天促销模式。 |
|
| `mode` | `promotion` | 开启聊天促销模式。 |
|
||||||
| `promotion_type` | `voice`、`image`、`private` | 指定促销消息类型,仅与 `mode=promotion` 一起使用。 |
|
| `promotion_type` | `voice`、`image`、`private` | 指定促销消息类型,仅与 `mode=promotion` 一起使用。 |
|
||||||
@@ -38,6 +39,12 @@ PSID 保存、登录状态判断和 Facebook Identity 绑定逻辑见 [PSID 与
|
|||||||
https://<APP_HOST>/external-entry?target=chat
|
https://<APP_HOST>/external-entry?target=chat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
进入 Maya 聊天:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<APP_HOST>/external-entry?target=chat&character=maya
|
||||||
|
```
|
||||||
|
|
||||||
携带 PSID 进入聊天,示例 PSID 为 `27511427698460020`:
|
携带 PSID 进入聊天,示例 PSID 为 `27511427698460020`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -74,6 +81,12 @@ https://<APP_HOST>/external-entry?target=tip
|
|||||||
https://<APP_HOST>/external-entry?target=private-room
|
https://<APP_HOST>/external-entry?target=private-room
|
||||||
```
|
```
|
||||||
|
|
||||||
|
进入 Nayeli 私密空间:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<APP_HOST>/external-entry?target=private-room&character=nayeli
|
||||||
|
```
|
||||||
|
|
||||||
`psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。
|
`psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。
|
||||||
|
|
||||||
测试环境和正式环境的完整可点击示例见 [外部入口链接清单](./links.md)。
|
测试环境和正式环境的完整可点击示例见 [外部入口链接清单](./links.md)。
|
||||||
|
|||||||
@@ -9,23 +9,35 @@
|
|||||||
| 入口 | 链接 |
|
| 入口 | 链接 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 普通聊天 | [打开普通聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat) |
|
| 普通聊天 | [打开普通聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat) |
|
||||||
|
| Maya 聊天 | [打开 Maya 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=maya) |
|
||||||
|
| Nayeli 聊天 | [打开 Nayeli 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=nayeli) |
|
||||||
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](https://frontend-test.banlv-ai.com/external-entry?target=chat&psid=27511427698460020) |
|
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](https://frontend-test.banlv-ai.com/external-entry?target=chat&psid=27511427698460020) |
|
||||||
| 语音促销 | [打开语音促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=voice) |
|
| 语音促销 | [打开语音促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=voice) |
|
||||||
| 图片促销 | [打开图片促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=image) |
|
| 图片促销 | [打开图片促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=image) |
|
||||||
| 私密文本促销 | [打开私密文本促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=private) |
|
| 私密文本促销 | [打开私密文本促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=private) |
|
||||||
| 咖啡打赏 | [打开咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip) |
|
| 咖啡打赏 | [打开咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip) |
|
||||||
|
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=maya) |
|
||||||
|
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=nayeli) |
|
||||||
| 私密空间 | [打开私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room) |
|
| 私密空间 | [打开私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room) |
|
||||||
|
| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=maya) |
|
||||||
|
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=nayeli) |
|
||||||
|
|
||||||
## 正式环境
|
## 正式环境
|
||||||
|
|
||||||
| 入口 | 链接 |
|
| 入口 | 链接 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 普通聊天 | [打开普通聊天](https://cozsweet.com/external-entry?target=chat) |
|
| 普通聊天 | [打开普通聊天](https://cozsweet.com/external-entry?target=chat) |
|
||||||
|
| Maya 聊天 | [打开 Maya 聊天](https://cozsweet.com/external-entry?target=chat&character=maya) |
|
||||||
|
| Nayeli 聊天 | [打开 Nayeli 聊天](https://cozsweet.com/external-entry?target=chat&character=nayeli) |
|
||||||
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](https://cozsweet.com/external-entry?target=chat&psid=27511427698460020) |
|
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](https://cozsweet.com/external-entry?target=chat&psid=27511427698460020) |
|
||||||
| 语音促销 | [打开语音促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=voice) |
|
| 语音促销 | [打开语音促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=voice) |
|
||||||
| 图片促销 | [打开图片促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=image) |
|
| 图片促销 | [打开图片促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=image) |
|
||||||
| 私密文本促销 | [打开私密文本促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=private) |
|
| 私密文本促销 | [打开私密文本促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=private) |
|
||||||
| 咖啡打赏 | [打开咖啡打赏](https://cozsweet.com/external-entry?target=tip) |
|
| 咖啡打赏 | [打开咖啡打赏](https://cozsweet.com/external-entry?target=tip) |
|
||||||
|
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=maya) |
|
||||||
|
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=nayeli) |
|
||||||
| 私密空间 | [打开私密空间](https://cozsweet.com/external-entry?target=private-room) |
|
| 私密空间 | [打开私密空间](https://cozsweet.com/external-entry?target=private-room) |
|
||||||
|
| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-room&character=maya) |
|
||||||
|
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-room&character=nayeli) |
|
||||||
|
|
||||||
如需在其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。
|
如需在其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 814 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 652 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 907 KiB |
@@ -10,6 +10,7 @@ export type AppBottomNavVariant = "warm" | "dark";
|
|||||||
export interface AppBottomNavProps {
|
export interface AppBottomNavProps {
|
||||||
activeItem?: AppBottomNavItem | null;
|
activeItem?: AppBottomNavItem | null;
|
||||||
variant?: AppBottomNavVariant;
|
variant?: AppBottomNavVariant;
|
||||||
|
privateRoomLabel?: string;
|
||||||
onChatClick: () => void;
|
onChatClick: () => void;
|
||||||
onPrivateRoomClick: () => void;
|
onPrivateRoomClick: () => void;
|
||||||
}
|
}
|
||||||
@@ -17,6 +18,7 @@ export interface AppBottomNavProps {
|
|||||||
export function AppBottomNav({
|
export function AppBottomNav({
|
||||||
activeItem = null,
|
activeItem = null,
|
||||||
variant = "warm",
|
variant = "warm",
|
||||||
|
privateRoomLabel = "Elio Private room",
|
||||||
onChatClick,
|
onChatClick,
|
||||||
onPrivateRoomClick,
|
onPrivateRoomClick,
|
||||||
}: AppBottomNavProps) {
|
}: AppBottomNavProps) {
|
||||||
@@ -42,7 +44,7 @@ export function AppBottomNav({
|
|||||||
onClick={onPrivateRoomClick}
|
onClick={onPrivateRoomClick}
|
||||||
>
|
>
|
||||||
<Camera size={20} aria-hidden="true" />
|
<Camera size={20} aria-hidden="true" />
|
||||||
<span>Elio Private room</span>
|
<span>{privateRoomLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface UsePaymentLaunchFlowInput {
|
|||||||
payment: PaymentContextState;
|
payment: PaymentContextState;
|
||||||
paymentDispatch: Dispatch<PaymentEvent>;
|
paymentDispatch: Dispatch<PaymentEvent>;
|
||||||
returnTo?: PendingPaymentReturnTo;
|
returnTo?: PendingPaymentReturnTo;
|
||||||
|
characterSlug?: string;
|
||||||
subscriptionType: PendingPaymentSubscriptionType;
|
subscriptionType: PendingPaymentSubscriptionType;
|
||||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||||
}
|
}
|
||||||
@@ -107,6 +108,7 @@ export function usePaymentLaunchFlow({
|
|||||||
payment,
|
payment,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
returnTo,
|
returnTo,
|
||||||
|
characterSlug,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
tipCoffeeType,
|
tipCoffeeType,
|
||||||
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
||||||
@@ -153,6 +155,7 @@ export function usePaymentLaunchFlow({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||||
...(returnTo ? { returnTo } : {}),
|
...(returnTo ? { returnTo } : {}),
|
||||||
|
...(characterSlug ? { characterSlug } : {}),
|
||||||
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
|
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
|
||||||
onFailed: (errorMessage) => {
|
onFailed: (errorMessage) => {
|
||||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||||
@@ -183,6 +186,7 @@ export function usePaymentLaunchFlow({
|
|||||||
}, [
|
}, [
|
||||||
log,
|
log,
|
||||||
logScope,
|
logScope,
|
||||||
|
characterSlug,
|
||||||
payment.currentOrderId,
|
payment.currentOrderId,
|
||||||
payment,
|
payment,
|
||||||
payment.launchNonce,
|
payment.launchNonce,
|
||||||
@@ -236,6 +240,7 @@ export function usePaymentLaunchFlow({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||||
...(returnTo ? { returnTo } : {}),
|
...(returnTo ? { returnTo } : {}),
|
||||||
|
...(characterSlug ? { characterSlug } : {}),
|
||||||
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
|
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
|
||||||
onFailed: (errorMessage) => {
|
onFailed: (errorMessage) => {
|
||||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCharacterBySlug } from "@/data/constants/character";
|
||||||
|
import { ChatRouteProviders } from "@/providers/chat-route-providers";
|
||||||
|
|
||||||
|
export default async function CharacterChatLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ characterSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { characterSlug } = await params;
|
||||||
|
const character = getCharacterBySlug(characterSlug);
|
||||||
|
if (!character) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatRouteProviders characterId={character.id}>
|
||||||
|
{children}
|
||||||
|
</ChatRouteProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { PageLoadingFallback } from "@/app/_components/core";
|
||||||
|
import { ChatScreen } from "@/app/chat/chat-screen";
|
||||||
|
|
||||||
|
export default function CharacterChatPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ChatFallback />}>
|
||||||
|
<ChatScreen />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatFallback() {
|
||||||
|
return (
|
||||||
|
<PageLoadingFallback background="#111111" tone="dark">
|
||||||
|
Loading chat...
|
||||||
|
</PageLoadingFallback>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { CoinsRulesScreen } from "@/app/coins-rules/coins-rules-screen";
|
||||||
|
|
||||||
|
export default function CharacterCoinsRulesPage() {
|
||||||
|
return <CoinsRulesScreen />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { FeedbackScreen } from "@/app/feedback/feedback-screen";
|
||||||
|
|
||||||
|
export default function CharacterFeedbackPage() {
|
||||||
|
return <FeedbackScreen />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHARACTERS,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
import { CharacterProvider } from "@/providers/character-provider";
|
||||||
|
|
||||||
|
export const dynamicParams = false;
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return CHARACTERS.map((character) => ({
|
||||||
|
characterSlug: character.slug,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CharacterLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ characterSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { characterSlug } = await params;
|
||||||
|
const character = getCharacterBySlug(characterSlug);
|
||||||
|
if (!character) notFound();
|
||||||
|
|
||||||
|
return <CharacterProvider character={character}>{children}</CharacterProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCharacterBySlug } from "@/data/constants/character";
|
||||||
|
import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider";
|
||||||
|
|
||||||
|
export default async function CharacterPrivateRoomLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ characterSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { characterSlug } = await params;
|
||||||
|
const character = getCharacterBySlug(characterSlug);
|
||||||
|
if (!character) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrivateRoomRouteProvider characterId={character.id}>
|
||||||
|
{children}
|
||||||
|
</PrivateRoomRouteProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrivateRoomScreen } from "@/app/private-room/private-room-screen";
|
||||||
|
|
||||||
|
export default function CharacterPrivateRoomPage() {
|
||||||
|
return <PrivateRoomScreen />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SidebarScreen } from "@/app/sidebar/sidebar-screen";
|
||||||
|
|
||||||
|
export default function CharacterSidebarPage() {
|
||||||
|
return <SidebarScreen />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SplashScreen } from "@/app/splash/splash-screen";
|
||||||
|
|
||||||
|
export default function CharacterSplashPage() {
|
||||||
|
return <SplashScreen />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
||||||
|
|
||||||
|
export default function CharacterTipLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <PaymentRouteProvider>{children}</PaymentRouteProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
getFirstPaymentSearchParam,
|
||||||
|
parsePaymentReturnSearchParams,
|
||||||
|
type PaymentSearchParams,
|
||||||
|
} from "@/lib/payment/payment_search_params";
|
||||||
|
import {
|
||||||
|
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({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<PaymentSearchParams>;
|
||||||
|
}) {
|
||||||
|
const query = await searchParams;
|
||||||
|
const paymentReturn = parsePaymentReturnSearchParams(query);
|
||||||
|
const coffeeType =
|
||||||
|
resolveTipCoffeeType(
|
||||||
|
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]),
|
||||||
|
) ?? DEFAULT_TIP_COFFEE_TYPE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TipScreen
|
||||||
|
coffeeType={coffeeType}
|
||||||
|
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
||||||
|
initialPayChannel={paymentReturn.initialPayChannel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,18 +9,22 @@ export function getChatImageOverlayMessageId(input: {
|
|||||||
return value && value.length > 0 ? value : null;
|
return value && value.length > 0 ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildChatImageOverlayUrl(messageId: string): `/chat?${string}` {
|
export function buildChatImageOverlayUrl(
|
||||||
|
messageId: string,
|
||||||
|
basePath: string = ROUTES.chat,
|
||||||
|
): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
[CHAT_IMAGE_QUERY_PARAM]: messageId,
|
[CHAT_IMAGE_QUERY_PARAM]: messageId,
|
||||||
});
|
});
|
||||||
return `${ROUTES.chat}?${params.toString()}` as const;
|
return `${basePath}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildChatWithoutImageOverlayUrl(input: {
|
export function buildChatWithoutImageOverlayUrl(
|
||||||
toString: () => string;
|
input: { toString: () => string },
|
||||||
}): string {
|
basePath: string = ROUTES.chat,
|
||||||
|
): string {
|
||||||
const params = new URLSearchParams(input.toString());
|
const params = new URLSearchParams(input.toString());
|
||||||
params.delete(CHAT_IMAGE_QUERY_PARAM);
|
params.delete(CHAT_IMAGE_QUERY_PARAM);
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return query ? `${ROUTES.chat}?${query}` : ROUTES.chat;
|
return query ? `${basePath}?${query}` : basePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useChatDispatch,
|
useChatDispatch,
|
||||||
useChatState,
|
useChatState,
|
||||||
} from "@/stores/chat/chat-context";
|
} from "@/stores/chat/chat-context";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ const chatShellStyle = {
|
|||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
@@ -63,10 +64,10 @@ export function ChatScreen() {
|
|||||||
: state.historyMessages;
|
: state.historyMessages;
|
||||||
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
||||||
const imageReturnUrl = imageMessageId
|
const imageReturnUrl = imageMessageId
|
||||||
? buildChatImageOverlayUrl(imageMessageId)
|
? buildChatImageOverlayUrl(imageMessageId, characterRoutes.chat)
|
||||||
: ROUTES.chat;
|
: characterRoutes.chat;
|
||||||
const unlockCoordinator = useChatUnlockCoordinator({
|
const unlockCoordinator = useChatUnlockCoordinator({
|
||||||
defaultReturnUrl: ROUTES.chat,
|
defaultReturnUrl: characterRoutes.chat,
|
||||||
imageMessageId,
|
imageMessageId,
|
||||||
imageReturnUrl,
|
imageReturnUrl,
|
||||||
promotion: state.promotion,
|
promotion: state.promotion,
|
||||||
@@ -113,10 +114,12 @@ export function ChatScreen() {
|
|||||||
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
|
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
|
router.replace(
|
||||||
scroll: false,
|
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||||
});
|
{ scroll: false },
|
||||||
|
);
|
||||||
}, [
|
}, [
|
||||||
|
characterRoutes.chat,
|
||||||
imageMessageId,
|
imageMessageId,
|
||||||
router,
|
router,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -137,13 +140,16 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenImage(messageId: string): void {
|
function handleOpenImage(messageId: string): void {
|
||||||
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
|
router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCloseImageViewer(): void {
|
function handleCloseImageViewer(): void {
|
||||||
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
|
router.replace(
|
||||||
scroll: false,
|
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||||
});
|
{ scroll: false },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockImagePaywall(): void {
|
function handleUnlockImagePaywall(): void {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { getCharacterBySlug } from "@/data/constants/character";
|
||||||
|
import { CharacterProvider } from "@/providers/character-provider";
|
||||||
|
|
||||||
import { BrowserHintOverlay } from "../browser-hint-overlay";
|
import { BrowserHintOverlay } from "../browser-hint-overlay";
|
||||||
import { ChatHeader } from "../chat-header";
|
import { ChatHeader } from "../chat-header";
|
||||||
import { ChatInsufficientCreditsBanner } from "../chat-insufficient-credits-banner";
|
import { ChatInsufficientCreditsBanner } from "../chat-insufficient-credits-banner";
|
||||||
@@ -35,6 +38,19 @@ describe("chat Tailwind components", () => {
|
|||||||
expect(userHtml).toContain("%2Fuser-avatar.png");
|
expect(userHtml).toContain("%2Fuser-avatar.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the active character avatar", () => {
|
||||||
|
const maya = getCharacterBySlug("maya");
|
||||||
|
expect(maya).not.toBeNull();
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CharacterProvider character={maya!}>
|
||||||
|
<MessageAvatar isFromAI={true} />
|
||||||
|
</CharacterProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("%2Fimages%2Favatar%2Fmaya.png");
|
||||||
|
expect(html).toContain('alt="Maya Tan"');
|
||||||
|
});
|
||||||
|
|
||||||
it("renders TextBubble AI and user visual variants", () => {
|
it("renders TextBubble AI and user visual variants", () => {
|
||||||
const aiHtml = renderToStaticMarkup(
|
const aiHtml = renderToStaticMarkup(
|
||||||
<TextBubble content={"hello\nthere"} isFromAI={true} />,
|
<TextBubble content={"hello\nthere"} isFromAI={true} />,
|
||||||
@@ -172,7 +188,7 @@ describe("chat Tailwind components", () => {
|
|||||||
expect(guestHtml).not.toContain('aria-label="Back to home"');
|
expect(guestHtml).not.toContain('aria-label="Back to home"');
|
||||||
expect(guestHtml).not.toContain('aria-label="Menu"');
|
expect(guestHtml).not.toContain('aria-label="Menu"');
|
||||||
expect(memberHtml).toContain("Offer");
|
expect(memberHtml).toContain("Offer");
|
||||||
expect(memberHtml).toContain('href="/splash"');
|
expect(memberHtml).toContain('href="/characters/elio/splash"');
|
||||||
expect(memberHtml).toContain('aria-label="Back to home"');
|
expect(memberHtml).toContain('aria-label="Back to home"');
|
||||||
expect(memberHtml).toContain('aria-label="Menu"');
|
expect(memberHtml).toContain('aria-label="Menu"');
|
||||||
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
|
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
|
|||||||
import { Lock, Menu } from "lucide-react";
|
import { Lock, Menu } from "lucide-react";
|
||||||
|
|
||||||
import { BackButton } from "@/app/_components";
|
import { BackButton } from "@/app/_components";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
|
||||||
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
||||||
@@ -26,6 +26,7 @@ export function ChatHeader({
|
|||||||
showBrowserHint = false,
|
showBrowserHint = false,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
|
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
|
||||||
@@ -35,7 +36,7 @@ export function ChatHeader({
|
|||||||
data-analytics-key="chat.open_signup"
|
data-analytics-key="chat.open_signup"
|
||||||
data-analytics-label="Open sign up"
|
data-analytics-label="Open sign up"
|
||||||
className="flex w-full cursor-pointer items-center justify-center gap-(--spacing-sm,8px) border-0 bg-accent px-(--spacing-md,12px) py-(--spacing-sm,8px) text-center text-(length:--responsive-caption,var(--font-size-sm,12px)) font-medium text-white"
|
className="flex w-full cursor-pointer items-center justify-center gap-(--spacing-sm,8px) border-0 bg-accent px-(--spacing-md,12px) py-(--spacing-sm,8px) text-center text-(length:--responsive-caption,var(--font-size-sm,12px)) font-medium text-white"
|
||||||
onClick={() => navigator.openAuth(ROUTES.chat)}
|
onClick={() => navigator.openAuth(characterRoutes.chat)}
|
||||||
aria-label="Sign up to unlock more features"
|
aria-label="Sign up to unlock more features"
|
||||||
>
|
>
|
||||||
<Lock
|
<Lock
|
||||||
@@ -52,7 +53,7 @@ export function ChatHeader({
|
|||||||
{!isGuest ? (
|
{!isGuest ? (
|
||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-(--spacing-sm,8px) px-(--spacing-md,12px) py-(--spacing-sm,8px)">
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-(--spacing-sm,8px) px-(--spacing-md,12px) py-(--spacing-sm,8px)">
|
||||||
<BackButton
|
<BackButton
|
||||||
href={ROUTES.splash}
|
href={characterRoutes.splash}
|
||||||
variant="dark"
|
variant="dark"
|
||||||
ariaLabel="Back to home"
|
ariaLabel="Back to home"
|
||||||
analyticsKey="chat.back_to_home"
|
analyticsKey="chat.back_to_home"
|
||||||
@@ -67,7 +68,7 @@ export function ChatHeader({
|
|||||||
data-analytics-key="chat.open_menu"
|
data-analytics-key="chat.open_menu"
|
||||||
data-analytics-label="Open chat menu"
|
data-analytics-label="Open chat menu"
|
||||||
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||||
onClick={() => navigator.push(ROUTES.sidebar)}
|
onClick={() => navigator.push(characterRoutes.sidebar)}
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
>
|
>
|
||||||
<Menu size={24} aria-hidden="true" />
|
<Menu size={24} aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ImageIcon, LockKeyhole } from "lucide-react";
|
import { ImageIcon, LockKeyhole } from "lucide-react";
|
||||||
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
|
|
||||||
export interface LockedImageMessageCardProps {
|
export interface LockedImageMessageCardProps {
|
||||||
hint?: string | null;
|
hint?: string | null;
|
||||||
@@ -13,6 +14,8 @@ export function LockedImageMessageCard({
|
|||||||
isUnlocking = false,
|
isUnlocking = false,
|
||||||
onUnlock,
|
onUnlock,
|
||||||
}: LockedImageMessageCardProps) {
|
}: LockedImageMessageCardProps) {
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-[min(72vw,280px)] overflow-hidden rounded-(--responsive-card-radius-sm,18px) rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-white shadow-[0_4px_14px_rgba(246,87,160,0.14)]"
|
className="w-[min(72vw,280px)] overflow-hidden rounded-(--responsive-card-radius-sm,18px) rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-white shadow-[0_4px_14px_rgba(246,87,160,0.14)]"
|
||||||
@@ -29,7 +32,7 @@ export function LockedImageMessageCard({
|
|||||||
<p className="m-0 text-(length:--responsive-body,14px) leading-[1.4] text-[#3c3b3b]">
|
<p className="m-0 text-(length:--responsive-body,14px) leading-[1.4] text-[#3c3b3b]">
|
||||||
{hint && hint.length > 0
|
{hint && hint.length > 0
|
||||||
? hint
|
? hint
|
||||||
: "Elio sent you a locked image."}
|
: `${character.shortName} sent you a locked image.`}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { UserMessageAvatar } from "@/app/_components";
|
import { UserMessageAvatar } from "@/app/_components";
|
||||||
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
|
|
||||||
export interface MessageAvatarProps {
|
export interface MessageAvatarProps {
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
@@ -12,12 +13,14 @@ const AVATAR_CLASS_NAME =
|
|||||||
"flex size-(--chat-avatar-size,43px) shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
|
"flex size-(--chat-avatar-size,43px) shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
|
||||||
|
|
||||||
export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
|
export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
|
||||||
if (isFromAI) {
|
if (isFromAI) {
|
||||||
return (
|
return (
|
||||||
<div className={AVATAR_CLASS_NAME} aria-label="AI avatar">
|
<div className={AVATAR_CLASS_NAME} aria-label="AI avatar">
|
||||||
<Image
|
<Image
|
||||||
src="/images/avatar/elio.png"
|
src={character.assets.avatar}
|
||||||
alt="Elio"
|
alt={character.displayName}
|
||||||
width={43}
|
width={43}
|
||||||
height={43}
|
height={43}
|
||||||
priority
|
priority
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LockKeyhole } from "lucide-react";
|
import { LockKeyhole } from "lucide-react";
|
||||||
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
|
|
||||||
export interface PrivateMessageCardProps {
|
export interface PrivateMessageCardProps {
|
||||||
hint?: string | null;
|
hint?: string | null;
|
||||||
@@ -13,6 +14,8 @@ export function PrivateMessageCard({
|
|||||||
isUnlocking = false,
|
isUnlocking = false,
|
||||||
onUnlock,
|
onUnlock,
|
||||||
}: PrivateMessageCardProps) {
|
}: PrivateMessageCardProps) {
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="max-w-[min(72vw,280px)] rounded-(--responsive-card-radius-sm,18px) rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-[linear-gradient(180deg,rgba(255,244,248,0.95),rgba(255,255,255,0.95)),#ffffff] p-(--responsive-card-padding,14px) text-[#3c3b3b] shadow-[0_4px_12px_rgba(246,87,160,0.12)]"
|
className="max-w-[min(72vw,280px)] rounded-(--responsive-card-radius-sm,18px) rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-[linear-gradient(180deg,rgba(255,244,248,0.95),rgba(255,255,255,0.95)),#ffffff] p-(--responsive-card-padding,14px) text-[#3c3b3b] shadow-[0_4px_12px_rgba(246,87,160,0.12)]"
|
||||||
@@ -28,7 +31,7 @@ export function PrivateMessageCard({
|
|||||||
<p className="m-0 text-(length:--responsive-body,14px) leading-[1.4] text-[#3c3b3b]">
|
<p className="m-0 text-(length:--responsive-body,14px) leading-[1.4] text-[#3c3b3b]">
|
||||||
{hint && hint.length > 0
|
{hint && hint.length > 0
|
||||||
? hint
|
? hint
|
||||||
: "Elio has a private message for you."}
|
: `${character.shortName} has a private message for you.`}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LockKeyhole, Pause, Play } from "lucide-react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||||
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
import styles from "./voice-bubble.module.css";
|
import styles from "./voice-bubble.module.css";
|
||||||
|
|
||||||
export interface VoiceBubbleProps {
|
export interface VoiceBubbleProps {
|
||||||
@@ -27,6 +28,7 @@ export function VoiceBubble({
|
|||||||
isUnlocking = false,
|
isUnlocking = false,
|
||||||
onUnlock,
|
onUnlock,
|
||||||
}: VoiceBubbleProps) {
|
}: VoiceBubbleProps) {
|
||||||
|
const character = useActiveCharacter();
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null);
|
const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null);
|
||||||
@@ -123,7 +125,7 @@ export function VoiceBubble({
|
|||||||
<p className={styles.hint}>
|
<p className={styles.hint}>
|
||||||
{hint && hint.length > 0
|
{hint && hint.length > 0
|
||||||
? hint
|
? hint
|
||||||
: "Elio has a locked voice message for you."}
|
: `${character.shortName} has a locked voice message for you.`}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ChatRouteProviders } from "@/providers/chat-route-providers";
|
export default function LegacyChatLayout({
|
||||||
|
children,
|
||||||
export default function ChatLayout({ children }: { children: ReactNode }) {
|
}: {
|
||||||
return <ChatRouteProviders>{children}</ChatRouteProviders>;
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Suspense } from "react";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { PageLoadingFallback } from "@/app/_components/core";
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
import { ChatScreen } from "@/app/chat/chat-screen";
|
import {
|
||||||
|
appendRouteSearchParams,
|
||||||
|
getCharacterRoutes,
|
||||||
|
type RouteSearchParams,
|
||||||
|
} from "@/router/routes";
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default async function ChatPage({
|
||||||
return (
|
searchParams,
|
||||||
<Suspense fallback={<ChatFallback />}>
|
}: {
|
||||||
<ChatScreen />
|
searchParams: Promise<RouteSearchParams>;
|
||||||
</Suspense>
|
}) {
|
||||||
);
|
redirect(
|
||||||
}
|
appendRouteSearchParams(
|
||||||
|
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).chat,
|
||||||
function ChatFallback() {
|
await searchParams,
|
||||||
return (
|
),
|
||||||
<PageLoadingFallback background="#111111" tone="dark">
|
|
||||||
Loading chat...
|
|
||||||
</PageLoadingFallback>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { FaCoins } from "react-icons/fa6";
|
|||||||
|
|
||||||
import { BackButton } from "@/app/_components";
|
import { BackButton } from "@/app/_components";
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ const createCoinRules = (
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function CoinsRulesScreen() {
|
export function CoinsRulesScreen() {
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
const userDispatch = useUserDispatch();
|
const userDispatch = useUserDispatch();
|
||||||
@@ -110,7 +111,7 @@ export function CoinsRulesScreen() {
|
|||||||
<main className={styles.screen}>
|
<main className={styles.screen}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<BackButton
|
<BackButton
|
||||||
href={ROUTES.sidebar}
|
href={characterRoutes.sidebar}
|
||||||
variant="soft"
|
variant="soft"
|
||||||
aria-label="Back to sidebar"
|
aria-label="Back to sidebar"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface ExternalEntryPersistProps {
|
|||||||
psid: string | null;
|
psid: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
target: string | null;
|
target: string | null;
|
||||||
|
character: string | null;
|
||||||
mode: string | null;
|
mode: string | null;
|
||||||
promotionType: string | null;
|
promotionType: string | null;
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,7 @@ export default function ExternalEntryPersist({
|
|||||||
psid,
|
psid,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
target,
|
target,
|
||||||
|
character,
|
||||||
mode,
|
mode,
|
||||||
promotionType,
|
promotionType,
|
||||||
}: ExternalEntryPersistProps) {
|
}: ExternalEntryPersistProps) {
|
||||||
@@ -46,7 +48,7 @@ export default function ExternalEntryPersist({
|
|||||||
const hasNavigatedRef = useRef(false);
|
const hasNavigatedRef = useRef(false);
|
||||||
const hasReinitializedForPsidRef = useRef(false);
|
const hasReinitializedForPsidRef = useRef(false);
|
||||||
const targetRoute = resolveExternalEntryTarget({ target });
|
const targetRoute = resolveExternalEntryTarget({ target });
|
||||||
const destination = resolveExternalEntryDestination({ target });
|
const destination = resolveExternalEntryDestination({ target, character });
|
||||||
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
||||||
mode,
|
mode,
|
||||||
promotionType,
|
promotionType,
|
||||||
@@ -84,6 +86,7 @@ export default function ExternalEntryPersist({
|
|||||||
}, [
|
}, [
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
asid,
|
asid,
|
||||||
|
character,
|
||||||
destination,
|
destination,
|
||||||
deviceId,
|
deviceId,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如:
|
* 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如:
|
||||||
* `/external-entry?target=chat&asid=xxx&psid=yyy`
|
* `/external-entry?target=chat&asid=xxx&psid=yyy`
|
||||||
* `/external-entry?target=chat&mode=promotion&promotion_type=voice`
|
* `/external-entry?target=chat&mode=promotion&promotion_type=voice`
|
||||||
|
* `/external-entry?target=chat&character=maya`
|
||||||
* `/external-entry?target=tip`
|
* `/external-entry?target=tip`
|
||||||
*
|
*
|
||||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||||
@@ -30,6 +31,7 @@ export default async function ExternalEntryPage({
|
|||||||
psid={pickParam(params.psid)}
|
psid={pickParam(params.psid)}
|
||||||
avatarUrl={pickParam(params.avatar_url)}
|
avatarUrl={pickParam(params.avatar_url)}
|
||||||
target={pickParam(params.target)}
|
target={pickParam(params.target)}
|
||||||
|
character={pickParam(params.character)}
|
||||||
mode={pickParam(params.mode)}
|
mode={pickParam(params.mode)}
|
||||||
promotionType={pickParam(params.promotion_type)}
|
promotionType={pickParam(params.promotion_type)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { BackButton } from "@/app/_components";
|
import { BackButton } from "@/app/_components";
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
import type { FeedbackCategory } from "@/data/schemas/feedback";
|
import type { FeedbackCategory } from "@/data/schemas/feedback";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +46,7 @@ const CATEGORY_OPTIONS: readonly CategoryOption[] = [
|
|||||||
|
|
||||||
export function FeedbackScreen() {
|
export function FeedbackScreen() {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const form = useFeedbackSubmission();
|
const form = useFeedbackSubmission();
|
||||||
|
|
||||||
if (form.feedbackId) {
|
if (form.feedbackId) {
|
||||||
@@ -70,7 +71,7 @@ export function FeedbackScreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="feedback.back_to_chat"
|
data-analytics-key="feedback.back_to_chat"
|
||||||
className={styles.primaryButton}
|
className={styles.primaryButton}
|
||||||
onClick={() => navigator.replace(ROUTES.chat)}
|
onClick={() => navigator.replace(characterRoutes.chat)}
|
||||||
>
|
>
|
||||||
Back to Chat
|
Back to Chat
|
||||||
</button>
|
</button>
|
||||||
@@ -93,7 +94,7 @@ export function FeedbackScreen() {
|
|||||||
|
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<BackButton
|
<BackButton
|
||||||
href={ROUTES.chat}
|
href={characterRoutes.chat}
|
||||||
variant="soft"
|
variant="soft"
|
||||||
ariaLabel="Back to chat"
|
ariaLabel="Back to chat"
|
||||||
analyticsKey="feedback.back_to_chat"
|
analyticsKey="feedback.back_to_chat"
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
redirect(ROUTES.splash);
|
redirect(getCharacterRoutes(DEFAULT_CHARACTER_SLUG).splash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { LoginStatus } from "@/data/schemas/auth";
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
import type { PrivateRoomEvent } from "@/stores/private-room";
|
import type { PrivateRoomEvent } from "@/stores/private-room";
|
||||||
import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state";
|
import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state";
|
||||||
|
|
||||||
@@ -79,7 +79,9 @@ describe("Private Room paywall navigation", () => {
|
|||||||
|
|
||||||
renderHarness(root, "guest", roomDispatch);
|
renderHarness(root, "guest", roomDispatch);
|
||||||
|
|
||||||
expect(mocks.openAuth).toHaveBeenCalledWith(ROUTES.privateRoom);
|
expect(mocks.openAuth).toHaveBeenCalledWith(
|
||||||
|
getCharacterRoutes("elio").privateRoom,
|
||||||
|
);
|
||||||
expect(mocks.openSubscription).not.toHaveBeenCalled();
|
expect(mocks.openSubscription).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider";
|
export default function LegacyPrivateRoomLayout({
|
||||||
|
|
||||||
export default function PrivateRoomLayout({
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <PrivateRoomRouteProvider>{children}</PrivateRoomRouteProvider>;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { PrivateRoomScreen } from "./private-room-screen";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function PrivateRoomPage() {
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
return <PrivateRoomScreen />;
|
import {
|
||||||
|
appendRouteSearchParams,
|
||||||
|
getCharacterRoutes,
|
||||||
|
type RouteSearchParams,
|
||||||
|
} from "@/router/routes";
|
||||||
|
|
||||||
|
export default async function PrivateRoomPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<RouteSearchParams>;
|
||||||
|
}) {
|
||||||
|
redirect(
|
||||||
|
appendRouteSearchParams(
|
||||||
|
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).privateRoom,
|
||||||
|
await searchParams,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,20 +15,22 @@ export function getPrivateAlbumGalleryState(input: {
|
|||||||
export function buildPrivateAlbumGalleryUrl(
|
export function buildPrivateAlbumGalleryUrl(
|
||||||
albumId: string,
|
albumId: string,
|
||||||
imageIndex: number,
|
imageIndex: number,
|
||||||
): `/private-room?${string}` {
|
basePath: string = ROUTES.privateRoom,
|
||||||
|
): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
[PRIVATE_ALBUM_QUERY_PARAM]: albumId,
|
[PRIVATE_ALBUM_QUERY_PARAM]: albumId,
|
||||||
[PRIVATE_ALBUM_IMAGE_QUERY_PARAM]: String(imageIndex),
|
[PRIVATE_ALBUM_IMAGE_QUERY_PARAM]: String(imageIndex),
|
||||||
});
|
});
|
||||||
return `${ROUTES.privateRoom}?${params.toString()}` as const;
|
return `${basePath}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPrivateRoomWithoutGalleryUrl(input: {
|
export function buildPrivateRoomWithoutGalleryUrl(
|
||||||
toString: () => string;
|
input: { toString: () => string },
|
||||||
}): string {
|
basePath: string = ROUTES.privateRoom,
|
||||||
|
): string {
|
||||||
const params = new URLSearchParams(input.toString());
|
const params = new URLSearchParams(input.toString());
|
||||||
params.delete(PRIVATE_ALBUM_QUERY_PARAM);
|
params.delete(PRIVATE_ALBUM_QUERY_PARAM);
|
||||||
params.delete(PRIVATE_ALBUM_IMAGE_QUERY_PARAM);
|
params.delete(PRIVATE_ALBUM_IMAGE_QUERY_PARAM);
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return query ? `${ROUTES.privateRoom}?${query}` : ROUTES.privateRoom;
|
return query ? `${basePath}?${query}` : basePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
|
|
||||||
import { CharacterAvatar } from "@/app/_components";
|
import { CharacterAvatar } from "@/app/_components";
|
||||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||||
import { ROUTES } from "@/router/routes";
|
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import {
|
||||||
|
useActiveCharacter,
|
||||||
|
useActiveCharacterRoutes,
|
||||||
|
} from "@/providers/character-provider";
|
||||||
import { isPrivateAlbumLocked } from "@/lib/private-room/private_album";
|
import { isPrivateAlbumLocked } from "@/lib/private-room/private_album";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import {
|
import {
|
||||||
@@ -34,17 +37,12 @@ import {
|
|||||||
getPrivateAlbumGalleryState,
|
getPrivateAlbumGalleryState,
|
||||||
} from "./private-album-gallery-url";
|
} from "./private-album-gallery-url";
|
||||||
|
|
||||||
const FALLBACK_PROFILE = {
|
|
||||||
name: "Elio Silvestri",
|
|
||||||
avatar: "/images/avatar/elio.png",
|
|
||||||
title: "Elio Private room",
|
|
||||||
subtitle: "Join me, unlock my private room",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function PrivateRoomScreen() {
|
export function PrivateRoomScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
const state = usePrivateRoomState();
|
const state = usePrivateRoomState();
|
||||||
@@ -70,10 +68,10 @@ export function PrivateRoomScreen() {
|
|||||||
});
|
});
|
||||||
usePrivateRoomUnlockSuccessRefresh(state.unlockSuccessNonce);
|
usePrivateRoomUnlockSuccessRefresh(state.unlockSuccessNonce);
|
||||||
|
|
||||||
const displayName = FALLBACK_PROFILE.name;
|
const displayName = character.displayName;
|
||||||
const avatarUrl = FALLBACK_PROFILE.avatar;
|
const avatarUrl = character.assets.avatar;
|
||||||
const title = FALLBACK_PROFILE.title;
|
const title = character.copy.privateRoomTitle;
|
||||||
const subtitle = FALLBACK_PROFILE.subtitle;
|
const subtitle = character.copy.privateRoomSubtitle;
|
||||||
const pendingAlbum = useMemo(
|
const pendingAlbum = useMemo(
|
||||||
() =>
|
() =>
|
||||||
state.items.find(
|
state.items.find(
|
||||||
@@ -98,15 +96,26 @@ export function PrivateRoomScreen() {
|
|||||||
isPrivateAlbumLocked(galleryAlbum) ||
|
isPrivateAlbumLocked(galleryAlbum) ||
|
||||||
!image?.url
|
!image?.url
|
||||||
) {
|
) {
|
||||||
router.replace(buildPrivateRoomWithoutGalleryUrl(searchParams), {
|
router.replace(
|
||||||
scroll: false,
|
buildPrivateRoomWithoutGalleryUrl(
|
||||||
});
|
searchParams,
|
||||||
|
characterRoutes.privateRoom,
|
||||||
|
),
|
||||||
|
{ scroll: false },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [galleryAlbum, galleryState, router, searchParams, state.isLoading]);
|
}, [
|
||||||
|
characterRoutes.privateRoom,
|
||||||
|
galleryAlbum,
|
||||||
|
galleryState,
|
||||||
|
router,
|
||||||
|
searchParams,
|
||||||
|
state.isLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleTopUpClick = () => {
|
const handleTopUpClick = () => {
|
||||||
if (isPrivateRoomAuthRequired(authState.loginStatus)) {
|
if (isPrivateRoomAuthRequired(authState.loginStatus)) {
|
||||||
navigator.openAuth(ROUTES.privateRoom);
|
navigator.openAuth(characterRoutes.privateRoom);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigator.openSubscription({
|
navigator.openSubscription({
|
||||||
@@ -121,7 +130,10 @@ export function PrivateRoomScreen() {
|
|||||||
|
|
||||||
const handleOpenGallery = (albumId: string) => {
|
const handleOpenGallery = (albumId: string) => {
|
||||||
openedGalleryInPageRef.current = true;
|
openedGalleryInPageRef.current = true;
|
||||||
router.push(buildPrivateAlbumGalleryUrl(albumId, 0), { scroll: false });
|
router.push(
|
||||||
|
buildPrivateAlbumGalleryUrl(albumId, 0, characterRoutes.privateRoom),
|
||||||
|
{ scroll: false },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseGallery = () => {
|
const handleCloseGallery = () => {
|
||||||
@@ -130,15 +142,23 @@ export function PrivateRoomScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildPrivateRoomWithoutGalleryUrl(searchParams), {
|
router.replace(
|
||||||
scroll: false,
|
buildPrivateRoomWithoutGalleryUrl(
|
||||||
});
|
searchParams,
|
||||||
|
characterRoutes.privateRoom,
|
||||||
|
),
|
||||||
|
{ scroll: false },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGalleryIndexChange = (imageIndex: number) => {
|
const handleGalleryIndexChange = (imageIndex: number) => {
|
||||||
if (!galleryAlbum) return;
|
if (!galleryAlbum) return;
|
||||||
router.replace(
|
router.replace(
|
||||||
buildPrivateAlbumGalleryUrl(galleryAlbum.albumId, imageIndex),
|
buildPrivateAlbumGalleryUrl(
|
||||||
|
galleryAlbum.albumId,
|
||||||
|
imageIndex,
|
||||||
|
characterRoutes.privateRoom,
|
||||||
|
),
|
||||||
{ scroll: false },
|
{ scroll: false },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -152,7 +172,7 @@ export function PrivateRoomScreen() {
|
|||||||
<section className={styles.hero} aria-label={title}>
|
<section className={styles.hero} aria-label={title}>
|
||||||
<div className={styles.heroCover}>
|
<div className={styles.heroCover}>
|
||||||
<Image
|
<Image
|
||||||
src="/images/private-room/banner/elio.png"
|
src={character.assets.privateRoomBanner}
|
||||||
alt=""
|
alt=""
|
||||||
width={2048}
|
width={2048}
|
||||||
height={1152}
|
height={1152}
|
||||||
@@ -241,7 +261,10 @@ export function PrivateRoomScreen() {
|
|||||||
|
|
||||||
<AppBottomNav
|
<AppBottomNav
|
||||||
activeItem="privateRoom"
|
activeItem="privateRoom"
|
||||||
onChatClick={() => navigator.push(ROUTES.splash, { scroll: false })}
|
privateRoomLabel={character.copy.privateRoomTitle}
|
||||||
|
onChatClick={() =>
|
||||||
|
navigator.push(characterRoutes.splash, { scroll: false })
|
||||||
|
}
|
||||||
onPrivateRoomClick={() => undefined}
|
onPrivateRoomClick={() => undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type Dispatch, useEffect, useRef } from "react";
|
|||||||
import type { LoginStatus } from "@/data/schemas/auth";
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
|
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
|
||||||
import { behaviorAnalytics } from "@/lib/analytics";
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import type { AuthEvent } from "@/stores/auth/auth-events";
|
import type { AuthEvent } from "@/stores/auth/auth-events";
|
||||||
import type { PrivateRoomEvent } from "@/stores/private-room";
|
import type { PrivateRoomEvent } from "@/stores/private-room";
|
||||||
@@ -78,12 +78,13 @@ export function usePrivateRoomUnlockPaywallNavigation({
|
|||||||
unlockPaywallRequest,
|
unlockPaywallRequest,
|
||||||
}: UsePrivateRoomUnlockPaywallNavigationInput): void {
|
}: UsePrivateRoomUnlockPaywallNavigationInput): void {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!unlockPaywallRequest) return;
|
if (!unlockPaywallRequest) return;
|
||||||
|
|
||||||
if (isPrivateRoomAuthRequired(loginStatus)) {
|
if (isPrivateRoomAuthRequired(loginStatus)) {
|
||||||
navigator.openAuth(ROUTES.privateRoom);
|
navigator.openAuth(characterRoutes.privateRoom);
|
||||||
} else {
|
} else {
|
||||||
behaviorAnalytics.paywallShown({
|
behaviorAnalytics.paywallShown({
|
||||||
entryPoint: "private_album_unlock",
|
entryPoint: "private_album_unlock",
|
||||||
@@ -99,7 +100,13 @@ export function usePrivateRoomUnlockPaywallNavigation({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
roomDispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
|
roomDispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
|
||||||
}, [loginStatus, navigator, roomDispatch, unlockPaywallRequest]);
|
}, [
|
||||||
|
characterRoutes.privateRoom,
|
||||||
|
loginStatus,
|
||||||
|
navigator,
|
||||||
|
roomDispatch,
|
||||||
|
unlockPaywallRequest,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePrivateRoomUnlockSuccessRefresh(
|
export function usePrivateRoomUnlockSuccessRefresh(
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { signOut } from "next-auth/react";
|
|||||||
|
|
||||||
import { BackButton } from "@/app/_components";
|
import { BackButton } from "@/app/_components";
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
import { ROUTES } from "@/router/routes";
|
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ import styles from "./components/sidebar-screen.module.css";
|
|||||||
|
|
||||||
export function SidebarScreen() {
|
export function SidebarScreen() {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
const userDispatch = useUserDispatch();
|
const userDispatch = useUserDispatch();
|
||||||
const auth = useAuthState();
|
const auth = useAuthState();
|
||||||
@@ -31,7 +32,7 @@ export function SidebarScreen() {
|
|||||||
userDispatch({ type: "UserClearLocal" });
|
userDispatch({ type: "UserClearLocal" });
|
||||||
authDispatch({ type: "AuthLogoutSubmitted" });
|
authDispatch({ type: "AuthLogoutSubmitted" });
|
||||||
void signOut({ redirect: false });
|
void signOut({ redirect: false });
|
||||||
navigator.replace(ROUTES.splash, { scroll: false });
|
navigator.replace(characterRoutes.splash, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const view = getSidebarViewModel({
|
const view = getSidebarViewModel({
|
||||||
@@ -47,7 +48,7 @@ export function SidebarScreen() {
|
|||||||
|
|
||||||
<div className={styles.topBar}>
|
<div className={styles.topBar}>
|
||||||
<BackButton
|
<BackButton
|
||||||
href={ROUTES.chat}
|
href={characterRoutes.chat}
|
||||||
variant="soft"
|
variant="soft"
|
||||||
analyticsKey="sidebar.back_to_chat"
|
analyticsKey="sidebar.back_to_chat"
|
||||||
/>
|
/>
|
||||||
@@ -59,7 +60,7 @@ export function SidebarScreen() {
|
|||||||
name={view.name}
|
name={view.name}
|
||||||
avatarUrl={view.avatarUrl}
|
avatarUrl={view.avatarUrl}
|
||||||
isLoading={view.isInitializingUser}
|
isLoading={view.isInitializingUser}
|
||||||
onLoginClick={() => navigator.openAuth(ROUTES.sidebar)}
|
onLoginClick={() => navigator.openAuth(characterRoutes.sidebar)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ export function SidebarScreen() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
|
onRulesClick={() => navigator.push(characterRoutes.coinsRules)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ export function SidebarScreen() {
|
|||||||
data-analytics-key="sidebar.open_feedback"
|
data-analytics-key="sidebar.open_feedback"
|
||||||
data-analytics-label="Open feedback"
|
data-analytics-label="Open feedback"
|
||||||
className={`${styles.logoutCard} ${styles.feedbackCard}`}
|
className={`${styles.logoutCard} ${styles.feedbackCard}`}
|
||||||
onClick={() => navigator.push(ROUTES.feedback)}
|
onClick={() => navigator.push(characterRoutes.feedback)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`${styles.logoutIcon} ${styles.feedbackIcon}`}
|
className={`${styles.logoutIcon} ${styles.feedbackIcon}`}
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
*
|
*
|
||||||
*
|
*
|
||||||
* `SizedBox.expand(child: Assets.images.picBgHome.image(fit: BoxFit.cover))`
|
* `SizedBox.expand(child: Assets.images.picBgHome.image(fit: BoxFit.cover))`
|
||||||
* 资源: /public/images/cover/elio.png
|
|
||||||
* 原名: pic_bg_home.png (snake_case)
|
|
||||||
*
|
|
||||||
* sizes 属性说明:
|
* sizes 属性说明:
|
||||||
* 图片在 MobileShell 内(max-width: 540px)。
|
* 图片在 MobileShell 内(max-width: 540px)。
|
||||||
* - 视口 ≤ 540px:MobileShell = 100vw,图片 = 100vw
|
* - 视口 ≤ 540px:MobileShell = 100vw,图片 = 100vw
|
||||||
@@ -13,11 +10,15 @@
|
|||||||
*/
|
*/
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
export function SplashBackground() {
|
export function SplashBackground({
|
||||||
|
src = "/images/cover/elio.png",
|
||||||
|
}: {
|
||||||
|
src?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-0 overflow-hidden bg-sidebar-background">
|
<div className="absolute inset-0 z-0 overflow-hidden bg-sidebar-background">
|
||||||
<Image
|
<Image
|
||||||
src="/images/cover/elio.png"
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import styles from "./splash-latest-message.module.css";
|
|||||||
|
|
||||||
export interface SplashLatestMessageProps {
|
export interface SplashLatestMessageProps {
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
characterName: string;
|
||||||
|
avatarUrl: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onOpenChat: () => void;
|
onOpenChat: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SplashLatestMessage({
|
export function SplashLatestMessage({
|
||||||
message,
|
message,
|
||||||
|
characterName,
|
||||||
|
avatarUrl,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onOpenChat,
|
onOpenChat,
|
||||||
}: SplashLatestMessageProps) {
|
}: SplashLatestMessageProps) {
|
||||||
@@ -26,10 +30,11 @@ export function SplashLatestMessage({
|
|||||||
data-analytics-label="Open latest message"
|
data-analytics-label="Open latest message"
|
||||||
className={styles.card}
|
className={styles.card}
|
||||||
onClick={onOpenChat}
|
onClick={onOpenChat}
|
||||||
aria-label="Open latest message from Elio"
|
aria-label={`Open latest message from ${characterName}`}
|
||||||
>
|
>
|
||||||
<span className={styles.avatarWrap}>
|
<span className={styles.avatarWrap}>
|
||||||
<CharacterAvatar
|
<CharacterAvatar
|
||||||
|
src={avatarUrl}
|
||||||
alt=""
|
alt=""
|
||||||
size="100%"
|
size="100%"
|
||||||
imageSize={42}
|
imageSize={42}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { LoginStatus } from "@/data/schemas/auth";
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||||
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
|
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
|
||||||
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
|
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
|
||||||
@@ -12,6 +13,7 @@ import { Result } from "@/utils/result";
|
|||||||
const log = new Logger("SplashLatestMessage");
|
const log = new Logger("SplashLatestMessage");
|
||||||
|
|
||||||
export interface UseSplashLatestMessageInput {
|
export interface UseSplashLatestMessageInput {
|
||||||
|
characterId?: string;
|
||||||
hasInitialized: boolean;
|
hasInitialized: boolean;
|
||||||
isAuthLoading: boolean;
|
isAuthLoading: boolean;
|
||||||
loginStatus: LoginStatus;
|
loginStatus: LoginStatus;
|
||||||
@@ -29,6 +31,7 @@ interface SplashLatestMessageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSplashLatestMessage({
|
export function useSplashLatestMessage({
|
||||||
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
hasInitialized,
|
hasInitialized,
|
||||||
isAuthLoading,
|
isAuthLoading,
|
||||||
loginStatus,
|
loginStatus,
|
||||||
@@ -45,7 +48,7 @@ export function useSplashLatestMessage({
|
|||||||
hasInitialized &&
|
hasInitialized &&
|
||||||
!isAuthLoading &&
|
!isAuthLoading &&
|
||||||
loginStatus !== "notLoggedIn";
|
loginStatus !== "notLoggedIn";
|
||||||
const requestKey = shouldLoad ? loginStatus : "";
|
const requestKey = shouldLoad ? `${loginStatus}:${characterId}` : "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldLoad) return;
|
if (!shouldLoad) return;
|
||||||
@@ -53,7 +56,10 @@ export function useSplashLatestMessage({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const loadLatestMessage = async () => {
|
const loadLatestMessage = async () => {
|
||||||
const result = await loadSplashLatestMessagePreview({ cache });
|
const result = await loadSplashLatestMessagePreview({
|
||||||
|
cache,
|
||||||
|
characterId,
|
||||||
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (Result.isErr(result)) {
|
if (Result.isErr(result)) {
|
||||||
@@ -77,7 +83,7 @@ export function useSplashLatestMessage({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [cache, requestKey, shouldLoad]);
|
}, [cache, characterId, requestKey, shouldLoad]);
|
||||||
|
|
||||||
if (!shouldLoad) return { message: null, isLoading: false };
|
if (!shouldLoad) return { message: null, isLoading: false };
|
||||||
if (state.key !== requestKey || !state.loaded) {
|
if (state.key !== requestKey || !state.loaded) {
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { SplashScreen } from "@/app/splash/splash-screen";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function SplashPage() {
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
return <SplashScreen />;
|
import {
|
||||||
|
appendRouteSearchParams,
|
||||||
|
getCharacterRoutes,
|
||||||
|
type RouteSearchParams,
|
||||||
|
} from "@/router/routes";
|
||||||
|
|
||||||
|
export default async function SplashPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<RouteSearchParams>;
|
||||||
|
}) {
|
||||||
|
redirect(
|
||||||
|
appendRouteSearchParams(
|
||||||
|
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).splash,
|
||||||
|
await searchParams,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||||
import { ROUTES } from "@/router/routes";
|
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import {
|
||||||
|
useActiveCharacter,
|
||||||
|
useActiveCharacterRoutes,
|
||||||
|
} from "@/providers/character-provider";
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { pwaUtil } from "@/utils/pwa";
|
import { pwaUtil } from "@/utils/pwa";
|
||||||
|
|
||||||
@@ -20,8 +23,11 @@ import styles from "./components/splash-screen.module.css";
|
|||||||
|
|
||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const latestMessage = useSplashLatestMessage({
|
const latestMessage = useSplashLatestMessage({
|
||||||
|
characterId: character.id,
|
||||||
hasInitialized: authState.hasInitialized,
|
hasInitialized: authState.hasInitialized,
|
||||||
isAuthLoading: authState.isLoading,
|
isAuthLoading: authState.isLoading,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
@@ -32,11 +38,11 @@ export function SplashScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenPrivateRoom = () => {
|
const handleOpenPrivateRoom = () => {
|
||||||
navigator.push(ROUTES.privateRoom, { scroll: false });
|
navigator.push(characterRoutes.privateRoom, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenSplash = () => {
|
const handleOpenSplash = () => {
|
||||||
navigator.push(ROUTES.splash, { scroll: false });
|
navigator.push(characterRoutes.splash, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,7 +52,7 @@ export function SplashScreen() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell background="var(--color-sidebar-background)">
|
<MobileShell background="var(--color-sidebar-background)">
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<SplashBackground />
|
<SplashBackground src={character.assets.cover} />
|
||||||
{/* 渐变叠加层:accent → transparent (bottom-left → center-right) */}
|
{/* 渐变叠加层:accent → transparent (bottom-left → center-right) */}
|
||||||
<div className={styles.gradientOverlay} aria-hidden="true" />
|
<div className={styles.gradientOverlay} aria-hidden="true" />
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -54,6 +60,8 @@ export function SplashScreen() {
|
|||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
<SplashLatestMessage
|
<SplashLatestMessage
|
||||||
message={latestMessage.message}
|
message={latestMessage.message}
|
||||||
|
characterName={character.shortName}
|
||||||
|
avatarUrl={character.assets.avatar}
|
||||||
isLoading={latestMessage.isLoading}
|
isLoading={latestMessage.isLoading}
|
||||||
onOpenChat={handleStartChat}
|
onOpenChat={handleStartChat}
|
||||||
/>
|
/>
|
||||||
@@ -62,7 +70,7 @@ export function SplashScreen() {
|
|||||||
<SplashButton onStartChat={handleStartChat} />
|
<SplashButton onStartChat={handleStartChat} />
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.bottom}>
|
<p className={styles.bottom}>
|
||||||
Elio Silvestri, Your exclusive AI boyfriend
|
{character.displayName}, {character.copy.splashRelationship}
|
||||||
<br />
|
<br />
|
||||||
24/7 online | Chat | Companion | Heal | Sweet moments
|
24/7 online | Chat | Companion | Heal | Sweet moments
|
||||||
</p>
|
</p>
|
||||||
@@ -70,6 +78,7 @@ export function SplashScreen() {
|
|||||||
<AppBottomNav
|
<AppBottomNav
|
||||||
activeItem="chat"
|
activeItem="chat"
|
||||||
variant="dark"
|
variant="dark"
|
||||||
|
privateRoomLabel={character.copy.privateRoomTitle}
|
||||||
onChatClick={handleOpenSplash}
|
onChatClick={handleOpenSplash}
|
||||||
onPrivateRoomClick={handleOpenPrivateRoom}
|
onPrivateRoomClick={handleOpenPrivateRoom}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||||
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
import {
|
import {
|
||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
@@ -22,12 +23,14 @@ export interface SubscriptionCheckoutButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
subscriptionType: "vip" | "topup";
|
subscriptionType: "vip" | "topup";
|
||||||
returnTo?: SubscriptionReturnTo;
|
returnTo?: SubscriptionReturnTo;
|
||||||
|
characterSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionCheckoutButton({
|
export function SubscriptionCheckoutButton({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
returnTo = null,
|
returnTo = null,
|
||||||
|
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
}: SubscriptionCheckoutButtonProps) {
|
}: SubscriptionCheckoutButtonProps) {
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
@@ -37,6 +40,7 @@ export function SubscriptionCheckoutButton({
|
|||||||
payment,
|
payment,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
returnTo: returnTo ?? undefined,
|
returnTo: returnTo ?? undefined,
|
||||||
|
characterSlug,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
parseSubscriptionReturnTo,
|
parseSubscriptionReturnTo,
|
||||||
type PaymentSearchParams,
|
type PaymentSearchParams,
|
||||||
} from "@/lib/payment/payment_search_params";
|
} from "@/lib/payment/payment_search_params";
|
||||||
|
import {
|
||||||
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SubscriptionScreen,
|
SubscriptionScreen,
|
||||||
@@ -25,6 +29,9 @@ export default async function SubscriptionPage({
|
|||||||
const subscriptionType = toSubscriptionType(
|
const subscriptionType = toSubscriptionType(
|
||||||
getFirstPaymentSearchParam(query.type),
|
getFirstPaymentSearchParam(query.type),
|
||||||
);
|
);
|
||||||
|
const characterSlug =
|
||||||
|
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
|
||||||
|
DEFAULT_CHARACTER_SLUG;
|
||||||
const analyticsContext = parsePaymentAnalyticsContext({
|
const analyticsContext = parsePaymentAnalyticsContext({
|
||||||
entryPoint: getFirstPaymentSearchParam(
|
entryPoint: getFirstPaymentSearchParam(
|
||||||
query[PAYMENT_ANALYTICS_ENTRY_PARAM],
|
query[PAYMENT_ANALYTICS_ENTRY_PARAM],
|
||||||
@@ -42,6 +49,7 @@ export default async function SubscriptionPage({
|
|||||||
returnTo={parseSubscriptionReturnTo(query.returnTo)}
|
returnTo={parseSubscriptionReturnTo(query.returnTo)}
|
||||||
initialPayChannel={paymentReturn.initialPayChannel}
|
initialPayChannel={paymentReturn.initialPayChannel}
|
||||||
analyticsContext={analyticsContext}
|
analyticsContext={analyticsContext}
|
||||||
|
characterSlug={characterSlug}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics
|
|||||||
import { AppConstants } from "@/core/app_constants";
|
import { AppConstants } from "@/core/app_constants";
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
import {
|
import {
|
||||||
behaviorAnalytics,
|
behaviorAnalytics,
|
||||||
getDefaultPaymentAnalyticsContext,
|
getDefaultPaymentAnalyticsContext,
|
||||||
@@ -42,6 +43,7 @@ export interface SubscriptionScreenProps {
|
|||||||
returnTo?: SubscriptionReturnTo;
|
returnTo?: SubscriptionReturnTo;
|
||||||
initialPayChannel?: PayChannel | null;
|
initialPayChannel?: PayChannel | null;
|
||||||
analyticsContext?: PaymentAnalyticsContext;
|
analyticsContext?: PaymentAnalyticsContext;
|
||||||
|
characterSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionScreen({
|
export function SubscriptionScreen({
|
||||||
@@ -50,6 +52,7 @@ export function SubscriptionScreen({
|
|||||||
returnTo = null,
|
returnTo = null,
|
||||||
initialPayChannel = null,
|
initialPayChannel = null,
|
||||||
analyticsContext: providedAnalyticsContext,
|
analyticsContext: providedAnalyticsContext,
|
||||||
|
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
}: SubscriptionScreenProps) {
|
}: SubscriptionScreenProps) {
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
const countryCode = userState.currentUser?.countryCode;
|
const countryCode = userState.currentUser?.countryCode;
|
||||||
@@ -70,6 +73,7 @@ export function SubscriptionScreen({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
shouldResumePendingOrder,
|
shouldResumePendingOrder,
|
||||||
returnTo,
|
returnTo,
|
||||||
|
characterSlug,
|
||||||
initialPayChannel: resolvedInitialPayChannel,
|
initialPayChannel: resolvedInitialPayChannel,
|
||||||
});
|
});
|
||||||
const canSubscribeVip = subscriptionType === "vip";
|
const canSubscribeVip = subscriptionType === "vip";
|
||||||
@@ -260,6 +264,7 @@ export function SubscriptionScreen({
|
|||||||
disabled={!canActivate}
|
disabled={!canActivate}
|
||||||
subscriptionType={subscriptionType}
|
subscriptionType={subscriptionType}
|
||||||
returnTo={returnTo}
|
returnTo={returnTo}
|
||||||
|
characterSlug={characterSlug}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
|
|
||||||
import type { SubscriptionType } from "./subscription-screen.helpers";
|
import type { SubscriptionType } from "./subscription-screen.helpers";
|
||||||
@@ -14,6 +15,7 @@ export interface UseSubscriptionPaymentFlowInput {
|
|||||||
shouldResumePendingOrder: boolean;
|
shouldResumePendingOrder: boolean;
|
||||||
returnTo: SubscriptionReturnTo;
|
returnTo: SubscriptionReturnTo;
|
||||||
initialPayChannel: PayChannel;
|
initialPayChannel: PayChannel;
|
||||||
|
characterSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubscriptionPaymentFlow({
|
export function useSubscriptionPaymentFlow({
|
||||||
@@ -21,6 +23,7 @@ export function useSubscriptionPaymentFlow({
|
|||||||
shouldResumePendingOrder,
|
shouldResumePendingOrder,
|
||||||
returnTo,
|
returnTo,
|
||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
|
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
}: UseSubscriptionPaymentFlowInput) {
|
}: UseSubscriptionPaymentFlowInput) {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||||
@@ -41,13 +44,13 @@ export function useSubscriptionPaymentFlow({
|
|||||||
}, [payment.currentOrderId, payment.isPaid]);
|
}, [payment.currentOrderId, payment.isPaid]);
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
navigator.exitSubscription(returnTo);
|
navigator.exitSubscription(returnTo, characterSlug);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePaymentSuccessClose = () => {
|
const handlePaymentSuccessClose = () => {
|
||||||
setShowPaymentSuccessDialog(false);
|
setShowPaymentSuccessDialog(false);
|
||||||
paymentDispatch({ type: "PaymentReset" });
|
paymentDispatch({ type: "PaymentReset" });
|
||||||
navigator.exitSubscriptionAfterSuccess(returnTo);
|
navigator.exitSubscriptionAfterSuccess(returnTo, characterSlug);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
export default function LegacyTipLayout({
|
||||||
|
children,
|
||||||
export default function TipLayout({ children }: { children: ReactNode }) {
|
}: {
|
||||||
return <PaymentRouteProvider>{children}</PaymentRouteProvider>;
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
import {
|
import { redirect } from "next/navigation";
|
||||||
getFirstPaymentSearchParam,
|
|
||||||
parsePaymentReturnSearchParams,
|
|
||||||
type PaymentSearchParams,
|
|
||||||
} from "@/lib/payment/payment_search_params";
|
|
||||||
import {
|
|
||||||
DEFAULT_TIP_COFFEE_TYPE,
|
|
||||||
resolveTipCoffeeType,
|
|
||||||
TIP_COFFEE_TYPE_PARAM,
|
|
||||||
} from "@/lib/tip/tip_coffee";
|
|
||||||
|
|
||||||
import { TipScreen } from "./tip-screen";
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
|
import {
|
||||||
|
appendRouteSearchParams,
|
||||||
|
getCharacterRoutes,
|
||||||
|
type RouteSearchParams,
|
||||||
|
} from "@/router/routes";
|
||||||
|
|
||||||
export default async function TipPage({
|
export default async function TipPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<PaymentSearchParams>;
|
searchParams: Promise<RouteSearchParams>;
|
||||||
}) {
|
}) {
|
||||||
const query = await searchParams;
|
redirect(
|
||||||
const paymentReturn = parsePaymentReturnSearchParams(query);
|
appendRouteSearchParams(
|
||||||
const coffeeType =
|
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).tip,
|
||||||
resolveTipCoffeeType(
|
await searchParams,
|
||||||
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]),
|
),
|
||||||
) ??
|
|
||||||
DEFAULT_TIP_COFFEE_TYPE;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TipScreen
|
|
||||||
coffeeType={coffeeType}
|
|
||||||
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
|
||||||
initialPayChannel={paymentReturn.initialPayChannel}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||||
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
|
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
|
||||||
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
import {
|
import {
|
||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
usePaymentState,
|
usePaymentState,
|
||||||
@@ -28,6 +29,7 @@ export function TipCheckoutButton({
|
|||||||
onOrder,
|
onOrder,
|
||||||
returnPath,
|
returnPath,
|
||||||
}: TipCheckoutButtonProps) {
|
}: TipCheckoutButtonProps) {
|
||||||
|
const character = useActiveCharacter();
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const paymentLaunch = usePaymentLaunchFlow({
|
const paymentLaunch = usePaymentLaunchFlow({
|
||||||
@@ -37,6 +39,7 @@ export function TipCheckoutButton({
|
|||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
subscriptionType: "tip",
|
subscriptionType: "tip",
|
||||||
tipCoffeeType: coffeeType,
|
tipCoffeeType: coffeeType,
|
||||||
|
characterSlug: character.slug,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
height: 330px;
|
height: 330px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 242, 232, 0.3), #fff2e8 96%),
|
linear-gradient(180deg, rgba(255, 242, 232, 0.3), #fff2e8 96%),
|
||||||
url("/images/cover/elio.png") center 18% / cover no-repeat;
|
var(--tip-cover-image) center 18% / cover no-repeat;
|
||||||
opacity: 0.36;
|
opacity: 0.36;
|
||||||
filter: saturate(0.95) blur(0.2px);
|
filter: saturate(0.95) blur(0.2px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ArrowLeft, Heart, Sparkles } from "lucide-react";
|
import { ArrowLeft, Heart, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
type TipCoffeeType,
|
type TipCoffeeType,
|
||||||
} from "@/lib/tip/tip_coffee";
|
} from "@/lib/tip/tip_coffee";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import {
|
||||||
|
useActiveCharacter,
|
||||||
|
useActiveCharacterRoutes,
|
||||||
|
} from "@/providers/character-provider";
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
|
|
||||||
import { TipCheckoutButton } from "./tip-checkout-button";
|
import { TipCheckoutButton } from "./tip-checkout-button";
|
||||||
@@ -52,11 +56,16 @@ export function TipScreen({
|
|||||||
initialPayChannel = null,
|
initialPayChannel = null,
|
||||||
}: TipScreenProps) {
|
}: TipScreenProps) {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const [selectedCoffeeType, setSelectedCoffeeType] =
|
const [selectedCoffeeType, setSelectedCoffeeType] =
|
||||||
useState<TipCoffeeType>(coffeeType);
|
useState<TipCoffeeType>(coffeeType);
|
||||||
const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
|
const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
|
||||||
const returnPath = buildTipCoffeePath(selectedCoffeeType);
|
const returnPath = buildTipCoffeePath(
|
||||||
|
selectedCoffeeType,
|
||||||
|
characterRoutes.tip,
|
||||||
|
);
|
||||||
const resolvedInitialPayChannel =
|
const resolvedInitialPayChannel =
|
||||||
initialPayChannel ?? navigator.getDefaultPayChannel();
|
initialPayChannel ?? navigator.getDefaultPayChannel();
|
||||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||||
@@ -155,7 +164,10 @@ export function TipScreen({
|
|||||||
}
|
}
|
||||||
if (!canCreateOrder) return;
|
if (!canCreateOrder) return;
|
||||||
|
|
||||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
paymentDispatch({
|
||||||
|
type: "PaymentCreateOrderSubmitted",
|
||||||
|
recipientCharacterId: character.id,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCoffeeTypeChange = (type: TipCoffeeType) => {
|
const handleCoffeeTypeChange = (type: TipCoffeeType) => {
|
||||||
@@ -182,7 +194,14 @@ export function TipScreen({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell background="#fff5ed">
|
<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.bgImage} aria-hidden="true" />
|
||||||
<div className={styles.bgGlowOne} aria-hidden="true" />
|
<div className={styles.bgGlowOne} aria-hidden="true" />
|
||||||
<div className={styles.bgGlowTwo} aria-hidden="true" />
|
<div className={styles.bgGlowTwo} aria-hidden="true" />
|
||||||
@@ -198,13 +217,14 @@ export function TipScreen({
|
|||||||
>
|
>
|
||||||
<ArrowLeft size={19} aria-hidden="true" />
|
<ArrowLeft size={19} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.headerPill}>Tip Elio</span>
|
<span className={styles.headerPill}>{character.copy.tipHeader}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className={styles.hero} aria-labelledby="tip-title">
|
<section className={styles.hero} aria-labelledby="tip-title">
|
||||||
<div className={styles.avatarRing}>
|
<div className={styles.avatarRing}>
|
||||||
<CharacterAvatar
|
<CharacterAvatar
|
||||||
alt="Elio Silvestri"
|
src={character.assets.avatar}
|
||||||
|
alt={character.displayName}
|
||||||
size="100%"
|
size="100%"
|
||||||
imageSize={88}
|
imageSize={88}
|
||||||
priority
|
priority
|
||||||
@@ -212,7 +232,7 @@ export function TipScreen({
|
|||||||
</div>
|
</div>
|
||||||
<p className={styles.eyebrow}>A little sweetness for today</p>
|
<p className={styles.eyebrow}>A little sweetness for today</p>
|
||||||
<h1 id="tip-title" className={styles.title}>
|
<h1 id="tip-title" className={styles.title}>
|
||||||
Buy Elio a coffee
|
{character.copy.tipTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Send a warm coffee tip and keep the private moments glowing.
|
Send a warm coffee tip and keep the private moments glowing.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHARACTERS,
|
||||||
|
DEFAULT_CHARACTER,
|
||||||
|
getCharacterById,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
|
||||||
|
describe("local character catalog", () => {
|
||||||
|
it("contains immutable and uniquely addressable character profiles", () => {
|
||||||
|
expect(CHARACTERS.map((character) => character.slug)).toEqual([
|
||||||
|
"elio",
|
||||||
|
"maya",
|
||||||
|
"nayeli",
|
||||||
|
]);
|
||||||
|
expect(new Set(CHARACTERS.map((character) => character.id)).size).toBe(
|
||||||
|
CHARACTERS.length,
|
||||||
|
);
|
||||||
|
expect(Object.isFrozen(CHARACTERS)).toBe(true);
|
||||||
|
expect(Object.isFrozen(DEFAULT_CHARACTER)).toBe(true);
|
||||||
|
expect(Object.isFrozen(DEFAULT_CHARACTER.assets)).toBe(true);
|
||||||
|
expect(Object.isFrozen(DEFAULT_CHARACTER.copy)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves characters by id and normalized slug", () => {
|
||||||
|
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan");
|
||||||
|
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
|
||||||
|
"Nayeli Cervantes",
|
||||||
|
);
|
||||||
|
expect(getCharacterById("missing")).toBeNull();
|
||||||
|
expect(getCharacterBySlug("missing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("references local assets that exist under public", () => {
|
||||||
|
for (const character of CHARACTERS) {
|
||||||
|
for (const assetPath of Object.values(character.assets)) {
|
||||||
|
expect(
|
||||||
|
existsSync(join(process.cwd(), "public", assetPath)),
|
||||||
|
assetPath,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,2 +1,104 @@
|
|||||||
export const DEFAULT_CHARACTER_ID = "character_elio";
|
export interface CharacterProfile {
|
||||||
export const DEFAULT_CHARACTER_SLUG = "elio";
|
readonly id: string;
|
||||||
|
readonly slug: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly shortName: string;
|
||||||
|
readonly assets: {
|
||||||
|
readonly avatar: string;
|
||||||
|
readonly cover: string;
|
||||||
|
readonly privateRoomBanner: string;
|
||||||
|
};
|
||||||
|
readonly copy: {
|
||||||
|
readonly splashRelationship: string;
|
||||||
|
readonly privateRoomTitle: string;
|
||||||
|
readonly privateRoomSubtitle: string;
|
||||||
|
readonly tipHeader: string;
|
||||||
|
readonly tipTitle: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
||||||
|
return Object.freeze({
|
||||||
|
...profile,
|
||||||
|
assets: Object.freeze(profile.assets),
|
||||||
|
copy: Object.freeze(profile.copy),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([
|
||||||
|
defineCharacter({
|
||||||
|
id: "character_elio",
|
||||||
|
slug: "elio",
|
||||||
|
displayName: "Elio Silvestri",
|
||||||
|
shortName: "Elio",
|
||||||
|
assets: {
|
||||||
|
avatar: "/images/avatar/elio.png",
|
||||||
|
cover: "/images/cover/elio.png",
|
||||||
|
privateRoomBanner: "/images/private-room/banner/elio.png",
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
splashRelationship: "Your exclusive AI boyfriend",
|
||||||
|
privateRoomTitle: "Elio Private room",
|
||||||
|
privateRoomSubtitle: "Join me, unlock my private room",
|
||||||
|
tipHeader: "Tip Elio",
|
||||||
|
tipTitle: "Buy Elio a coffee",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineCharacter({
|
||||||
|
id: "character_maya",
|
||||||
|
slug: "maya",
|
||||||
|
displayName: "Maya Tan",
|
||||||
|
shortName: "Maya",
|
||||||
|
assets: {
|
||||||
|
avatar: "/images/avatar/maya.png",
|
||||||
|
cover: "/images/cover/maya.png",
|
||||||
|
privateRoomBanner: "/images/private-room/banner/maya.png",
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
splashRelationship: "Your exclusive AI girlfriend",
|
||||||
|
privateRoomTitle: "Maya Private room",
|
||||||
|
privateRoomSubtitle: "Join me, unlock my private room",
|
||||||
|
tipHeader: "Tip Maya",
|
||||||
|
tipTitle: "Buy Maya a coffee",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineCharacter({
|
||||||
|
id: "character_nayeli",
|
||||||
|
slug: "nayeli",
|
||||||
|
displayName: "Nayeli Cervantes",
|
||||||
|
shortName: "Nayeli",
|
||||||
|
assets: {
|
||||||
|
avatar: "/images/avatar/nayeli.png",
|
||||||
|
cover: "/images/cover/nayeli.png",
|
||||||
|
privateRoomBanner: "/images/private-room/banner/nayeli.png",
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
splashRelationship: "Your exclusive AI girlfriend",
|
||||||
|
privateRoomTitle: "Nayeli Private room",
|
||||||
|
privateRoomSubtitle: "Join me, unlock my private room",
|
||||||
|
tipHeader: "Tip Nayeli",
|
||||||
|
tipTitle: "Buy Nayeli a coffee",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DEFAULT_CHARACTER = CHARACTERS[0];
|
||||||
|
export const DEFAULT_CHARACTER_ID = DEFAULT_CHARACTER.id;
|
||||||
|
export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug;
|
||||||
|
|
||||||
|
export function getCharacterById(
|
||||||
|
characterId: string | null | undefined,
|
||||||
|
): CharacterProfile | null {
|
||||||
|
if (!characterId) return null;
|
||||||
|
return CHARACTERS.find((character) => character.id === characterId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCharacterBySlug(
|
||||||
|
characterSlug: string | null | undefined,
|
||||||
|
): CharacterProfile | null {
|
||||||
|
const normalizedSlug = characterSlug?.trim().toLowerCase();
|
||||||
|
if (!normalizedSlug) return null;
|
||||||
|
return (
|
||||||
|
CHARACTERS.find((character) => character.slug === normalizedSlug) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { CharactersResponse } from "@/data/schemas/character";
|
|
||||||
import type { ICharacterRepository } from "@/data/repositories/interfaces";
|
|
||||||
import { CharacterApi, characterApi } from "@/data/services/api/character_api";
|
|
||||||
import { Result } from "@/utils/result";
|
|
||||||
|
|
||||||
import { createLazySingleton } from "./lazy_singleton";
|
|
||||||
|
|
||||||
export class CharacterRepository implements ICharacterRepository {
|
|
||||||
constructor(private readonly api: CharacterApi) {}
|
|
||||||
|
|
||||||
getCharacters(): Promise<Result<CharactersResponse>> {
|
|
||||||
return Result.wrap(() => this.api.getCharacters());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getCharacterRepository = createLazySingleton<ICharacterRepository>(
|
|
||||||
() => new CharacterRepository(characterApi),
|
|
||||||
);
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
export * from "./auth_repository";
|
export * from "./auth_repository";
|
||||||
export * from "./chat_repository";
|
export * from "./chat_repository";
|
||||||
export * from "./character_repository";
|
|
||||||
export * from "./feedback_repository";
|
export * from "./feedback_repository";
|
||||||
export * from "./metrics_repository";
|
export * from "./metrics_repository";
|
||||||
export * from "./payment_repository";
|
export * from "./payment_repository";
|
||||||
@@ -12,7 +11,6 @@ export * from "./private_room_repository";
|
|||||||
export * from "./user_repository";
|
export * from "./user_repository";
|
||||||
export * from "./interfaces/iauth_repository";
|
export * from "./interfaces/iauth_repository";
|
||||||
export * from "./interfaces/ichat_repository";
|
export * from "./interfaces/ichat_repository";
|
||||||
export * from "./interfaces/icharacter_repository";
|
|
||||||
export * from "./interfaces/ifeedback_repository";
|
export * from "./interfaces/ifeedback_repository";
|
||||||
export * from "./interfaces/imetrics_repository";
|
export * from "./interfaces/imetrics_repository";
|
||||||
export * from "./interfaces/ipayment_repository";
|
export * from "./interfaces/ipayment_repository";
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { CharactersResponse } from "@/data/schemas/character";
|
|
||||||
import type { Result } from "@/utils/result";
|
|
||||||
|
|
||||||
export interface ICharacterRepository {
|
|
||||||
getCharacters(): Promise<Result<CharactersResponse>>;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
export * from "./iauth_repository";
|
export * from "./iauth_repository";
|
||||||
export * from "./ichat_repository";
|
export * from "./ichat_repository";
|
||||||
export * from "./icharacter_repository";
|
|
||||||
export * from "./ifeedback_repository";
|
export * from "./ifeedback_repository";
|
||||||
export * from "./imetrics_repository";
|
export * from "./imetrics_repository";
|
||||||
export * from "./ipayment_repository";
|
export * from "./ipayment_repository";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface IPaymentRepository {
|
|||||||
planId: string,
|
planId: string,
|
||||||
payChannel: PayChannel,
|
payChannel: PayChannel,
|
||||||
autoRenew: boolean,
|
autoRenew: boolean,
|
||||||
|
recipientCharacterId?: string,
|
||||||
): Promise<Result<CreatePaymentOrderResponse>>;
|
): Promise<Result<CreatePaymentOrderResponse>>;
|
||||||
|
|
||||||
/** 查询支付订单状态。 */
|
/** 查询支付订单状态。 */
|
||||||
|
|||||||
@@ -72,11 +72,13 @@ export class PaymentRepository implements IPaymentRepository {
|
|||||||
planId: string,
|
planId: string,
|
||||||
payChannel: PayChannel,
|
payChannel: PayChannel,
|
||||||
autoRenew: boolean,
|
autoRenew: boolean,
|
||||||
|
recipientCharacterId?: string,
|
||||||
): Promise<Result<CreatePaymentOrderResponse>> {
|
): Promise<Result<CreatePaymentOrderResponse>> {
|
||||||
const request = CreatePaymentOrderRequestSchema.parse({
|
const request = CreatePaymentOrderRequestSchema.parse({
|
||||||
planId,
|
planId,
|
||||||
payChannel,
|
payChannel,
|
||||||
autoRenew,
|
autoRenew,
|
||||||
|
...(recipientCharacterId ? { recipientCharacterId } : {}),
|
||||||
});
|
});
|
||||||
return Result.wrap(() => this.api.createOrder(request));
|
return Result.wrap(() => this.api.createOrder(request));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { CharacterSchema } from "@/data/schemas/character";
|
|
||||||
|
|
||||||
describe("Character", () => {
|
|
||||||
it("keeps only the four public character fields", () => {
|
|
||||||
const character = CharacterSchema.parse({
|
|
||||||
id: "character_elio",
|
|
||||||
slug: "elio",
|
|
||||||
displayName: " Elio Silvestri ",
|
|
||||||
avatarUrl: "https://cdn.example.com/elio.jpg",
|
|
||||||
enabled: true,
|
|
||||||
sortOrder: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(character).toEqual({
|
|
||||||
id: "character_elio",
|
|
||||||
slug: "elio",
|
|
||||||
displayName: "Elio Silvestri",
|
|
||||||
avatarUrl: "https://cdn.example.com/elio.jpg",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unsafe slugs and non-HTTPS avatars", () => {
|
|
||||||
expect(() =>
|
|
||||||
CharacterSchema.parse({
|
|
||||||
id: "character_aria",
|
|
||||||
slug: "Aria Profile",
|
|
||||||
displayName: "Aria",
|
|
||||||
avatarUrl: "https://cdn.example.com/aria.jpg",
|
|
||||||
}),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
CharacterSchema.parse({
|
|
||||||
id: "character_aria",
|
|
||||||
slug: "aria",
|
|
||||||
displayName: "Aria",
|
|
||||||
avatarUrl: "http://cdn.example.com/aria.jpg",
|
|
||||||
}),
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const CharacterSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string().min(1).max(64),
|
|
||||||
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
||||||
displayName: z.string().trim().min(1).max(80),
|
|
||||||
avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
|
|
||||||
message: "avatarUrl must use HTTPS",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.readonly();
|
|
||||||
|
|
||||||
export type CharacterInput = z.input<typeof CharacterSchema>;
|
|
||||||
export type CharacterData = z.output<typeof CharacterSchema>;
|
|
||||||
|
|
||||||
export type Character = CharacterData;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { CharacterSchema } from "./character";
|
|
||||||
|
|
||||||
export const CharactersResponseSchema = z
|
|
||||||
.object({
|
|
||||||
items: z
|
|
||||||
.array(CharacterSchema)
|
|
||||||
.default(() => [])
|
|
||||||
.readonly(),
|
|
||||||
})
|
|
||||||
.readonly();
|
|
||||||
|
|
||||||
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
|
|
||||||
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
|
|
||||||
|
|
||||||
export type CharactersResponse = CharactersResponseData;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file Automatically generated by barrelsby.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./character";
|
|
||||||
export * from "./characters_response";
|
|
||||||
@@ -10,6 +10,7 @@ export const CreatePaymentOrderRequestSchema = z
|
|||||||
planId: z.string(),
|
planId: z.string(),
|
||||||
payChannel: PayChannelSchema,
|
payChannel: PayChannelSchema,
|
||||||
autoRenew: z.boolean(),
|
autoRenew: z.boolean(),
|
||||||
|
recipientCharacterId: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
.readonly();
|
.readonly();
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const httpClientMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../http_client", () => ({
|
|
||||||
httpClient: httpClientMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { CharacterApi } from "../character_api";
|
|
||||||
|
|
||||||
describe("CharacterApi", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
httpClientMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads characters and preserves the backend order", async () => {
|
|
||||||
httpClientMock.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: "character_aria",
|
|
||||||
slug: "aria",
|
|
||||||
displayName: "Aria",
|
|
||||||
avatarUrl: "https://cdn.example.com/aria.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "character_elio",
|
|
||||||
slug: "elio",
|
|
||||||
displayName: "Elio Silvestri",
|
|
||||||
avatarUrl: "https://cdn.example.com/elio.jpg",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await new CharacterApi().getCharacters();
|
|
||||||
|
|
||||||
expect(httpClientMock).toHaveBeenCalledWith("/api/characters");
|
|
||||||
expect(response.items.map((character) => character.id)).toEqual([
|
|
||||||
"character_aria",
|
|
||||||
"character_elio",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -24,6 +24,5 @@
|
|||||||
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
|
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
|
||||||
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
|
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
|
||||||
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
|
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
|
||||||
"feedback": { "method": "post", "path": "/api/feedback" },
|
"feedback": { "method": "post", "path": "/api/feedback" }
|
||||||
"characters": { "method": "get", "path": "/api/characters" }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,4 @@ export class ApiPath {
|
|||||||
// ============ 用户反馈相关 ============
|
// ============ 用户反馈相关 ============
|
||||||
static readonly feedback = apiContract.feedback.path;
|
static readonly feedback = apiContract.feedback.path;
|
||||||
|
|
||||||
// ============ 角色相关 ============
|
|
||||||
static readonly characters = apiContract.characters.path;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import {
|
|
||||||
CharactersResponse,
|
|
||||||
CharactersResponseSchema,
|
|
||||||
} from "@/data/schemas/character";
|
|
||||||
|
|
||||||
import { ApiPath } from "./api_path";
|
|
||||||
import { httpClient } from "./http_client";
|
|
||||||
import { type ApiEnvelope, unwrap } from "./response_helper";
|
|
||||||
|
|
||||||
export class CharacterApi {
|
|
||||||
async getCharacters(): Promise<CharactersResponse> {
|
|
||||||
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
|
|
||||||
return CharactersResponseSchema.parse(unwrap(envelope));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const characterApi = new CharacterApi();
|
|
||||||
@@ -6,7 +6,6 @@ export * from "./api_path";
|
|||||||
export * from "./api_result";
|
export * from "./api_result";
|
||||||
export * from "./auth_api";
|
export * from "./auth_api";
|
||||||
export * from "./chat_api";
|
export * from "./chat_api";
|
||||||
export * from "./character_api";
|
|
||||||
export * from "./feedback_api";
|
export * from "./feedback_api";
|
||||||
export * from "./http_client";
|
export * from "./http_client";
|
||||||
export * from "./metrics_api";
|
export * from "./metrics_api";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const PendingPaymentOrderSchema = z.object({
|
|||||||
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
||||||
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
|
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
|
||||||
returnTo: z.enum(["chat", "private-room"]).optional(),
|
returnTo: z.enum(["chat", "private-room"]).optional(),
|
||||||
|
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resolveExternalEntryDestination,
|
resolveExternalEntryDestination,
|
||||||
@@ -33,7 +33,21 @@ describe("external entry navigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("routes every tip entry to the tier selection page", () => {
|
it("routes every tip entry to the tier selection page", () => {
|
||||||
expect(resolveExternalEntryDestination({ target: "tip" })).toBe("/tip");
|
expect(resolveExternalEntryDestination({ target: "tip" })).toBe(
|
||||||
|
getCharacterRoutes("elio").tip,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a known character slug and falls back to Elio", () => {
|
||||||
|
expect(
|
||||||
|
resolveExternalEntryDestination({ target: "chat", character: "maya" }),
|
||||||
|
).toBe(getCharacterRoutes("maya").chat);
|
||||||
|
expect(
|
||||||
|
resolveExternalEntryDestination({
|
||||||
|
target: "private-room",
|
||||||
|
character: "unknown",
|
||||||
|
}),
|
||||||
|
).toBe(getCharacterRoutes("elio").privateRoom);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
|
|
||||||
import { consumePendingChatImageReturn } from "../chat_image_return_session";
|
import { consumePendingChatImageReturn } from "../chat_image_return_session";
|
||||||
import {
|
import {
|
||||||
@@ -53,16 +53,20 @@ describe("subscription exit helpers", () => {
|
|||||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||||
peekPendingChatUnlockMock.mockResolvedValue(null);
|
peekPendingChatUnlockMock.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(consumeSubscriptionExitUrl("chat")).resolves.toBe(ROUTES.chat);
|
await expect(consumeSubscriptionExitUrl("chat")).resolves.toBe(
|
||||||
|
getCharacterRoutes("elio").chat,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to sidebar by default", async () => {
|
it("falls back to sidebar by default", async () => {
|
||||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||||
peekPendingChatUnlockMock.mockResolvedValue(null);
|
peekPendingChatUnlockMock.mockResolvedValue(null);
|
||||||
|
|
||||||
expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
|
expect(getSubscriptionFallbackExitUrl(null)).toBe(
|
||||||
|
getCharacterRoutes("elio").sidebar,
|
||||||
|
);
|
||||||
await expect(consumeSubscriptionExitUrl(null)).resolves.toBe(
|
await expect(consumeSubscriptionExitUrl(null)).resolves.toBe(
|
||||||
ROUTES.sidebar,
|
getCharacterRoutes("elio").sidebar,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,10 +88,10 @@ describe("subscription exit helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(getSubscriptionFallbackExitUrl("private-room")).toBe(
|
expect(getSubscriptionFallbackExitUrl("private-room")).toBe(
|
||||||
ROUTES.privateRoom,
|
getCharacterRoutes("elio").privateRoom,
|
||||||
);
|
);
|
||||||
await expect(consumeSubscriptionExitUrl("private-room")).resolves.toBe(
|
await expect(consumeSubscriptionExitUrl("private-room")).resolves.toBe(
|
||||||
ROUTES.privateRoom,
|
getCharacterRoutes("elio").privateRoom,
|
||||||
);
|
);
|
||||||
expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled();
|
expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled();
|
||||||
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
|
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
@@ -106,8 +110,8 @@ describe("subscription exit helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
resolveSubscriptionSuccessExitUrl("private-room"),
|
resolveSubscriptionSuccessExitUrl("private-room", "maya"),
|
||||||
).resolves.toBe(ROUTES.privateRoom);
|
).resolves.toBe(getCharacterRoutes("maya").privateRoom);
|
||||||
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
|
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
|||||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||||
import type { LoginStatus } from "@/data/schemas/auth";
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
import { ROUTES } from "@/router/routes";
|
import {
|
||||||
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
export type ExternalEntryTarget =
|
export type ExternalEntryTarget =
|
||||||
| typeof ROUTES.chat
|
| typeof ROUTES.chat
|
||||||
@@ -20,6 +24,7 @@ export interface ExternalEntryPayload {
|
|||||||
|
|
||||||
export interface ExternalEntryTargetInput {
|
export interface ExternalEntryTargetInput {
|
||||||
target?: string | null;
|
target?: string | null;
|
||||||
|
character?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalEntryPromotionInput {
|
export interface ExternalEntryPromotionInput {
|
||||||
@@ -79,8 +84,15 @@ export function resolveExternalEntryTarget({
|
|||||||
|
|
||||||
export function resolveExternalEntryDestination({
|
export function resolveExternalEntryDestination({
|
||||||
target,
|
target,
|
||||||
|
character,
|
||||||
}: ExternalEntryTargetInput): string {
|
}: ExternalEntryTargetInput): string {
|
||||||
return resolveExternalEntryTarget({ target });
|
const characterSlug =
|
||||||
|
getCharacterBySlug(character)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||||
|
const routes = getCharacterRoutes(characterSlug);
|
||||||
|
const resolvedTarget = resolveExternalEntryTarget({ target });
|
||||||
|
if (resolvedTarget === ROUTES.tip) return routes.tip;
|
||||||
|
if (resolvedTarget === ROUTES.privateRoom) return routes.privateRoom;
|
||||||
|
return routes.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function persistExternalEntryPayload({
|
export async function persistExternalEntryPayload({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
||||||
|
|
||||||
import { consumePendingChatImageReturn } from "./chat_image_return_session";
|
import { consumePendingChatImageReturn } from "./chat_image_return_session";
|
||||||
@@ -30,29 +31,37 @@ export async function peekSubscriptionExplicitExitUrl(): Promise<string | null>
|
|||||||
|
|
||||||
export function getSubscriptionFallbackExitUrl(
|
export function getSubscriptionFallbackExitUrl(
|
||||||
returnTo: SubscriptionReturnTo,
|
returnTo: SubscriptionReturnTo,
|
||||||
|
characterSlug: string = DEFAULT_CHARACTER_SLUG,
|
||||||
): string {
|
): string {
|
||||||
if (returnTo === "chat") return ROUTES.chat;
|
const routes = getCharacterRoutes(characterSlug);
|
||||||
if (returnTo === "private-room") return ROUTES.privateRoom;
|
if (returnTo === "chat") return routes.chat;
|
||||||
return ROUTES.sidebar;
|
if (returnTo === "private-room") return routes.privateRoom;
|
||||||
|
return routes.sidebar;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumeSubscriptionExitUrl(
|
export async function consumeSubscriptionExitUrl(
|
||||||
returnTo: SubscriptionReturnTo,
|
returnTo: SubscriptionReturnTo,
|
||||||
|
characterSlug: string = DEFAULT_CHARACTER_SLUG,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (returnTo === "private-room") return ROUTES.privateRoom;
|
if (returnTo === "private-room") {
|
||||||
|
return getCharacterRoutes(characterSlug).privateRoom;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await consumeSubscriptionExplicitExitUrl()) ??
|
(await consumeSubscriptionExplicitExitUrl()) ??
|
||||||
getSubscriptionFallbackExitUrl(returnTo)
|
getSubscriptionFallbackExitUrl(returnTo, characterSlug)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveSubscriptionSuccessExitUrl(
|
export async function resolveSubscriptionSuccessExitUrl(
|
||||||
returnTo: SubscriptionReturnTo,
|
returnTo: SubscriptionReturnTo,
|
||||||
|
characterSlug: string = DEFAULT_CHARACTER_SLUG,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (returnTo === "private-room") return ROUTES.privateRoom;
|
if (returnTo === "private-room") {
|
||||||
|
return getCharacterRoutes(characterSlug).privateRoom;
|
||||||
|
}
|
||||||
|
|
||||||
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
|
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
|
||||||
if (pendingExitUrl) return pendingExitUrl;
|
if (pendingExitUrl) return pendingExitUrl;
|
||||||
return consumeSubscriptionExitUrl(returnTo);
|
return consumeSubscriptionExitUrl(returnTo, characterSlug);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ describe("pending payment order helpers", () => {
|
|||||||
returnTo: "chat",
|
returnTo: "chat",
|
||||||
subscriptionType: "topup",
|
subscriptionType: "topup",
|
||||||
}),
|
}),
|
||||||
).toBe("/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat");
|
).toBe(
|
||||||
|
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat&character=elio",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves private room returns when rebuilding Ezpay urls", () => {
|
it("preserves private room returns when rebuilding Ezpay urls", () => {
|
||||||
@@ -26,7 +28,7 @@ describe("pending payment order helpers", () => {
|
|||||||
subscriptionType: "topup",
|
subscriptionType: "topup",
|
||||||
}),
|
}),
|
||||||
).toBe(
|
).toBe(
|
||||||
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=private-room",
|
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=private-room&character=elio",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +61,9 @@ describe("pending payment order helpers", () => {
|
|||||||
subscriptionType: "tip",
|
subscriptionType: "tip",
|
||||||
tipCoffeeType: "large",
|
tipCoffeeType: "large",
|
||||||
}),
|
}),
|
||||||
).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large");
|
).toBe(
|
||||||
|
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults legacy tip payment returns to medium coffee", () => {
|
it("defaults legacy tip payment returns to medium coffee", () => {
|
||||||
@@ -68,6 +72,21 @@ describe("pending payment order helpers", () => {
|
|||||||
payChannel: "ezpay",
|
payChannel: "ezpay",
|
||||||
subscriptionType: "tip",
|
subscriptionType: "tip",
|
||||||
}),
|
}),
|
||||||
).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium");
|
).toBe(
|
||||||
|
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the character that started an Ezpay tip", () => {
|
||||||
|
expect(
|
||||||
|
buildPendingPaymentSubscriptionUrl({
|
||||||
|
payChannel: "ezpay",
|
||||||
|
subscriptionType: "tip",
|
||||||
|
tipCoffeeType: "small",
|
||||||
|
characterSlug: "nayeli",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"/characters/nayeli/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface LaunchEzpayRedirectInput {
|
|||||||
subscriptionType: PendingPaymentSubscriptionType;
|
subscriptionType: PendingPaymentSubscriptionType;
|
||||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||||
returnTo?: PendingPaymentReturnTo;
|
returnTo?: PendingPaymentReturnTo;
|
||||||
|
characterSlug?: string;
|
||||||
onOpened?: () => void;
|
onOpened?: () => void;
|
||||||
onFailed: (errorMessage: string) => void;
|
onFailed: (errorMessage: string) => void;
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,7 @@ export async function launchEzpayRedirect({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
tipCoffeeType,
|
tipCoffeeType,
|
||||||
returnTo,
|
returnTo,
|
||||||
|
characterSlug,
|
||||||
onOpened,
|
onOpened,
|
||||||
onFailed,
|
onFailed,
|
||||||
}: LaunchEzpayRedirectInput): Promise<void> {
|
}: LaunchEzpayRedirectInput): Promise<void> {
|
||||||
@@ -101,6 +103,7 @@ export async function launchEzpayRedirect({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||||
...(returnTo ? { returnTo } : {}),
|
...(returnTo ? { returnTo } : {}),
|
||||||
|
...(characterSlug ? { characterSlug } : {}),
|
||||||
});
|
});
|
||||||
if (Result.isErr(saveResult)) {
|
if (Result.isErr(saveResult)) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
PendingPaymentOrderStorage,
|
PendingPaymentOrderStorage,
|
||||||
type PendingPaymentOrder,
|
type PendingPaymentOrder,
|
||||||
} from "@/data/storage/payment/pending_payment_order_storage";
|
} from "@/data/storage/payment/pending_payment_order_storage";
|
||||||
import { ROUTES } from "@/router/routes";
|
import {
|
||||||
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TIP_COFFEE_TYPE,
|
DEFAULT_TIP_COFFEE_TYPE,
|
||||||
TIP_COFFEE_TYPE_PARAM,
|
TIP_COFFEE_TYPE_PARAM,
|
||||||
@@ -23,6 +27,7 @@ export function savePendingEzpayOrder(input: {
|
|||||||
subscriptionType: PendingPaymentSubscriptionType;
|
subscriptionType: PendingPaymentSubscriptionType;
|
||||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||||
returnTo?: PendingPaymentReturnTo;
|
returnTo?: PendingPaymentReturnTo;
|
||||||
|
characterSlug?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
}): Promise<Result<void>> {
|
}): Promise<Result<void>> {
|
||||||
return PendingPaymentOrderStorage.setPendingOrder({
|
return PendingPaymentOrderStorage.setPendingOrder({
|
||||||
@@ -31,6 +36,7 @@ export function savePendingEzpayOrder(input: {
|
|||||||
subscriptionType: input.subscriptionType,
|
subscriptionType: input.subscriptionType,
|
||||||
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}),
|
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}),
|
||||||
...(input.returnTo ? { returnTo: input.returnTo } : {}),
|
...(input.returnTo ? { returnTo: input.returnTo } : {}),
|
||||||
|
...(input.characterSlug ? { characterSlug: input.characterSlug } : {}),
|
||||||
createdAt: input.createdAt ?? Date.now(),
|
createdAt: input.createdAt ?? Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,9 +60,17 @@ export function clearPendingPaymentOrder(): Promise<Result<void>> {
|
|||||||
export function buildPendingPaymentSubscriptionUrl(
|
export function buildPendingPaymentSubscriptionUrl(
|
||||||
order: Pick<
|
order: Pick<
|
||||||
PendingPaymentOrder,
|
PendingPaymentOrder,
|
||||||
"payChannel" | "returnTo" | "subscriptionType" | "tipCoffeeType"
|
| "payChannel"
|
||||||
|
| "returnTo"
|
||||||
|
| "subscriptionType"
|
||||||
|
| "tipCoffeeType"
|
||||||
|
| "characterSlug"
|
||||||
>,
|
>,
|
||||||
): string {
|
): string {
|
||||||
|
const characterSlug =
|
||||||
|
getCharacterBySlug(order.characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||||
|
const characterRoutes = getCharacterRoutes(characterSlug);
|
||||||
|
|
||||||
if (order.subscriptionType === "tip") {
|
if (order.subscriptionType === "tip") {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
payChannel: order.payChannel,
|
payChannel: order.payChannel,
|
||||||
@@ -64,7 +78,7 @@ export function buildPendingPaymentSubscriptionUrl(
|
|||||||
[TIP_COFFEE_TYPE_PARAM]:
|
[TIP_COFFEE_TYPE_PARAM]:
|
||||||
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE,
|
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE,
|
||||||
});
|
});
|
||||||
return `${ROUTES.tip}?${params.toString()}`;
|
return `${characterRoutes.tip}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -73,5 +87,6 @@ export function buildPendingPaymentSubscriptionUrl(
|
|||||||
paymentReturn: "1",
|
paymentReturn: "1",
|
||||||
});
|
});
|
||||||
if (order.returnTo) params.set("returnTo", order.returnTo);
|
if (order.returnTo) params.set("returnTo", order.returnTo);
|
||||||
|
params.set("character", characterSlug);
|
||||||
return `${ROUTES.subscription}?${params.toString()}`;
|
return `${ROUTES.subscription}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption {
|
|||||||
return TIP_COFFEE_OPTION_BY_TYPE[type];
|
return TIP_COFFEE_OPTION_BY_TYPE[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTipCoffeePath(type: TipCoffeeType): string {
|
export function buildTipCoffeePath(
|
||||||
|
type: TipCoffeeType,
|
||||||
|
basePath: string = ROUTES.tip,
|
||||||
|
): string {
|
||||||
const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type });
|
const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type });
|
||||||
return `${ROUTES.tip}?${params.toString()}`;
|
return `${basePath}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_CHARACTER,
|
||||||
|
type CharacterProfile,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
import { getCharacterRoutes, type CharacterRoutes } from "@/router/routes";
|
||||||
|
|
||||||
|
const CharacterContext = createContext<CharacterProfile>(DEFAULT_CHARACTER);
|
||||||
|
|
||||||
|
export interface CharacterProviderProps {
|
||||||
|
character: CharacterProfile;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CharacterProvider({
|
||||||
|
character,
|
||||||
|
children,
|
||||||
|
}: CharacterProviderProps) {
|
||||||
|
return (
|
||||||
|
<CharacterContext.Provider value={character}>
|
||||||
|
{children}
|
||||||
|
</CharacterContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveCharacter(): CharacterProfile {
|
||||||
|
return useContext(CharacterContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveCharacterRoutes(): CharacterRoutes {
|
||||||
|
return getCharacterRoutes(useActiveCharacter().slug);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { getRouteAccess, isInternalRoute } from "../route-meta";
|
import { getRouteAccess, isInternalRoute } from "../route-meta";
|
||||||
import { ALL_STATIC_ROUTES, ROUTES } from "../routes";
|
import { ALL_STATIC_ROUTES, getCharacterRoutes, ROUTES } from "../routes";
|
||||||
|
|
||||||
describe("route meta", () => {
|
describe("route meta", () => {
|
||||||
it("classifies static routes by access level", () => {
|
it("classifies static routes by access level", () => {
|
||||||
@@ -21,6 +21,17 @@ describe("route meta", () => {
|
|||||||
expect(getRouteAccess("/chat/image/msg_1")).toBe("public");
|
expect(getRouteAccess("/chat/image/msg_1")).toBe("public");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies character-scoped routes by their page", () => {
|
||||||
|
const routes = getCharacterRoutes("maya");
|
||||||
|
expect(getRouteAccess(routes.splash)).toBe("authOnly");
|
||||||
|
expect(getRouteAccess(routes.chat)).toBe("guestEntry");
|
||||||
|
expect(getRouteAccess(routes.privateRoom)).toBe("guestEntry");
|
||||||
|
expect(getRouteAccess(routes.tip)).toBe("public");
|
||||||
|
expect(getRouteAccess(routes.sidebar)).toBe("session");
|
||||||
|
expect(getRouteAccess(routes.feedback)).toBe("session");
|
||||||
|
expect(getRouteAccess(routes.coinsRules)).toBe("public");
|
||||||
|
});
|
||||||
|
|
||||||
it("includes private room in static routes", () => {
|
it("includes private room in static routes", () => {
|
||||||
expect(ALL_STATIC_ROUTES).toContain(ROUTES.privateRoom);
|
expect(ALL_STATIC_ROUTES).toContain(ROUTES.privateRoom);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,8 +21,22 @@ const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
|
|||||||
[ROUTES.coinsRules]: "public",
|
[ROUTES.coinsRules]: "public",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CHARACTER_ROUTE_ACCESS: Readonly<Record<string, RouteAccess>> = {
|
||||||
|
splash: "authOnly",
|
||||||
|
chat: "guestEntry",
|
||||||
|
"private-room": "guestEntry",
|
||||||
|
tip: "public",
|
||||||
|
sidebar: "session",
|
||||||
|
feedback: "session",
|
||||||
|
"coins-rules": "public",
|
||||||
|
};
|
||||||
|
|
||||||
export function getRouteAccess(pathname: string): RouteAccess {
|
export function getRouteAccess(pathname: string): RouteAccess {
|
||||||
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
|
const staticAccess = STATIC_ROUTE_ACCESS[pathname];
|
||||||
|
if (staticAccess) return staticAccess;
|
||||||
|
|
||||||
|
const match = pathname.match(/^\/characters\/[^/]+\/([^/]+)\/?$/);
|
||||||
|
return (match?.[1] && CHARACTER_ROUTE_ACCESS[match[1]]) || "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isInternalRoute(value: string | null | undefined): boolean {
|
export function isInternalRoute(value: string | null | undefined): boolean {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
||||||
|
import type { CharacterProfile } from "@/data/constants/character";
|
||||||
import {
|
import {
|
||||||
PAYMENT_ANALYTICS_ENTRY_PARAM,
|
PAYMENT_ANALYTICS_ENTRY_PARAM,
|
||||||
PAYMENT_ANALYTICS_REASON_PARAM,
|
PAYMENT_ANALYTICS_REASON_PARAM,
|
||||||
@@ -32,6 +33,52 @@ export const ROUTES = {
|
|||||||
|
|
||||||
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
||||||
|
|
||||||
|
export interface CharacterRoutes {
|
||||||
|
readonly splash: string;
|
||||||
|
readonly chat: string;
|
||||||
|
readonly privateRoom: string;
|
||||||
|
readonly tip: string;
|
||||||
|
readonly sidebar: string;
|
||||||
|
readonly feedback: string;
|
||||||
|
readonly coinsRules: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCharacterRoutes(
|
||||||
|
characterSlug: CharacterProfile["slug"],
|
||||||
|
): CharacterRoutes {
|
||||||
|
const root = `/characters/${encodeURIComponent(characterSlug)}`;
|
||||||
|
return {
|
||||||
|
splash: `${root}/splash`,
|
||||||
|
chat: `${root}/chat`,
|
||||||
|
privateRoom: `${root}/private-room`,
|
||||||
|
tip: `${root}/tip`,
|
||||||
|
sidebar: `${root}/sidebar`,
|
||||||
|
feedback: `${root}/feedback`,
|
||||||
|
coinsRules: `${root}/coins-rules`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteSearchParams = Record<
|
||||||
|
string,
|
||||||
|
string | readonly string[] | undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function appendRouteSearchParams(
|
||||||
|
pathname: string,
|
||||||
|
searchParams: RouteSearchParams,
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(searchParams)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
params.set(key, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value?.forEach((item) => params.append(key, item));
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `${pathname}?${query}` : pathname;
|
||||||
|
}
|
||||||
|
|
||||||
/** 动态查询路由构造器 */
|
/** 动态查询路由构造器 */
|
||||||
export const ROUTE_BUILDERS = {
|
export const ROUTE_BUILDERS = {
|
||||||
subscription: (
|
subscription: (
|
||||||
@@ -39,12 +86,16 @@ export const ROUTE_BUILDERS = {
|
|||||||
options: {
|
options: {
|
||||||
payChannel?: PayChannel;
|
payChannel?: PayChannel;
|
||||||
returnTo?: Exclude<AppSubscriptionReturnTo, null>;
|
returnTo?: Exclude<AppSubscriptionReturnTo, null>;
|
||||||
|
characterSlug?: string;
|
||||||
analytics?: PaymentAnalyticsContext;
|
analytics?: PaymentAnalyticsContext;
|
||||||
} = {},
|
} = {},
|
||||||
): `/subscription?${string}` => {
|
): `/subscription?${string}` => {
|
||||||
const params = new URLSearchParams({ type });
|
const params = new URLSearchParams({ type });
|
||||||
if (options.payChannel) params.set("payChannel", options.payChannel);
|
if (options.payChannel) params.set("payChannel", options.payChannel);
|
||||||
if (options.returnTo) params.set("returnTo", options.returnTo);
|
if (options.returnTo) params.set("returnTo", options.returnTo);
|
||||||
|
if (options.characterSlug) {
|
||||||
|
params.set("character", options.characterSlug);
|
||||||
|
}
|
||||||
if (options.analytics) {
|
if (options.analytics) {
|
||||||
params.set(
|
params.set(
|
||||||
PAYMENT_ANALYTICS_ENTRY_PARAM,
|
PAYMENT_ANALYTICS_ENTRY_PARAM,
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ import {
|
|||||||
} from "@/lib/analytics";
|
} from "@/lib/analytics";
|
||||||
import { useAuthSelector } from "@/stores/auth/auth-context";
|
import { useAuthSelector } from "@/stores/auth/auth-context";
|
||||||
import { useUserSelector } from "@/stores/user/user-context";
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
|
import {
|
||||||
|
useActiveCharacter,
|
||||||
|
useActiveCharacterRoutes,
|
||||||
|
} from "@/providers/character-provider";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ROUTE_BUILDERS,
|
ROUTE_BUILDERS,
|
||||||
ROUTES,
|
|
||||||
type Route,
|
type Route,
|
||||||
} from "./routes";
|
} from "./routes";
|
||||||
import {
|
import {
|
||||||
@@ -39,8 +42,14 @@ export interface AppNavigator {
|
|||||||
openAuth: (redirectTo?: string) => void;
|
openAuth: (redirectTo?: string) => void;
|
||||||
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
|
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
|
||||||
openSubscription: (input: OpenSubscriptionInput) => void;
|
openSubscription: (input: OpenSubscriptionInput) => void;
|
||||||
exitSubscription: (returnTo: SubscriptionReturnTo) => void;
|
exitSubscription: (
|
||||||
exitSubscriptionAfterSuccess: (returnTo: SubscriptionReturnTo) => void;
|
returnTo: SubscriptionReturnTo,
|
||||||
|
characterSlug?: string,
|
||||||
|
) => void;
|
||||||
|
exitSubscriptionAfterSuccess: (
|
||||||
|
returnTo: SubscriptionReturnTo,
|
||||||
|
characterSlug?: string,
|
||||||
|
) => void;
|
||||||
startMessageUnlock: (input: StartMessageUnlockInput) => void;
|
startMessageUnlock: (input: StartMessageUnlockInput) => void;
|
||||||
openSubscriptionForPendingUnlock: (
|
openSubscriptionForPendingUnlock: (
|
||||||
input: OpenSubscriptionForPendingUnlockInput,
|
input: OpenSubscriptionForPendingUnlockInput,
|
||||||
@@ -51,6 +60,8 @@ export interface AppNavigator {
|
|||||||
|
|
||||||
export function useAppNavigator(): AppNavigator {
|
export function useAppNavigator(): AppNavigator {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const loginStatus = useAuthSelector((state) => state.context.loginStatus);
|
const loginStatus = useAuthSelector((state) => state.context.loginStatus);
|
||||||
const countryCode = useUserSelector(
|
const countryCode = useUserSelector(
|
||||||
(state) => state.context.currentUser?.countryCode,
|
(state) => state.context.currentUser?.countryCode,
|
||||||
@@ -69,18 +80,24 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
router.back();
|
router.back();
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const openAuth = useCallback((redirectTo: string = ROUTES.chat): void => {
|
const openAuth = useCallback(
|
||||||
|
(redirectTo: string = characterRoutes.chat): void => {
|
||||||
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
|
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
|
||||||
}, [router]);
|
},
|
||||||
|
[characterRoutes.chat, router],
|
||||||
|
);
|
||||||
|
|
||||||
const openChat = useCallback((options: { replace?: boolean; scroll?: boolean } = {}): void => {
|
const openChat = useCallback(
|
||||||
|
(options: { replace?: boolean; scroll?: boolean } = {}): void => {
|
||||||
const navOptions = { scroll: options.scroll ?? false };
|
const navOptions = { scroll: options.scroll ?? false };
|
||||||
if (options.replace) {
|
if (options.replace) {
|
||||||
router.replace(ROUTES.chat, navOptions);
|
router.replace(characterRoutes.chat, navOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(ROUTES.chat, navOptions);
|
router.push(characterRoutes.chat, navOptions);
|
||||||
}, [router]);
|
},
|
||||||
|
[characterRoutes.chat, router],
|
||||||
|
);
|
||||||
|
|
||||||
const getDefaultPayChannel = useCallback(() => {
|
const getDefaultPayChannel = useCallback(() => {
|
||||||
return getDefaultPayChannelForCountryCode(countryCode);
|
return getDefaultPayChannelForCountryCode(countryCode);
|
||||||
@@ -97,6 +114,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
const target = ROUTE_BUILDERS.subscription(type, {
|
const target = ROUTE_BUILDERS.subscription(type, {
|
||||||
payChannel,
|
payChannel,
|
||||||
returnTo: returnTo ?? undefined,
|
returnTo: returnTo ?? undefined,
|
||||||
|
characterSlug: character.slug,
|
||||||
analytics,
|
analytics,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,7 +131,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
}
|
}
|
||||||
router.push(nextUrl);
|
router.push(nextUrl);
|
||||||
},
|
},
|
||||||
[getDefaultPayChannel, loginStatus, router],
|
[character.slug, getDefaultPayChannel, loginStatus, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startMessageUnlock = useCallback(
|
const startMessageUnlock = useCallback(
|
||||||
@@ -185,17 +203,33 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
[getDefaultPayChannel, openSubscription],
|
[getDefaultPayChannel, openSubscription],
|
||||||
);
|
);
|
||||||
|
|
||||||
const exitSubscription = useCallback((returnTo: SubscriptionReturnTo): void => {
|
const exitSubscription = useCallback(
|
||||||
|
(
|
||||||
|
returnTo: SubscriptionReturnTo,
|
||||||
|
characterSlug: string = character.slug,
|
||||||
|
): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
router.replace(await consumeSubscriptionExitUrl(returnTo));
|
router.replace(
|
||||||
|
await consumeSubscriptionExitUrl(returnTo, characterSlug),
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
}, [router]);
|
},
|
||||||
|
[character.slug, router],
|
||||||
|
);
|
||||||
|
|
||||||
const exitSubscriptionAfterSuccess = useCallback((returnTo: SubscriptionReturnTo): void => {
|
const exitSubscriptionAfterSuccess = useCallback(
|
||||||
|
(
|
||||||
|
returnTo: SubscriptionReturnTo,
|
||||||
|
characterSlug: string = character.slug,
|
||||||
|
): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
router.replace(await resolveSubscriptionSuccessExitUrl(returnTo));
|
router.replace(
|
||||||
|
await resolveSubscriptionSuccessExitUrl(returnTo, characterSlug),
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
}, [router]);
|
},
|
||||||
|
[character.slug, router],
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
push,
|
push,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface CreateOrderInput {
|
|||||||
planId: string;
|
planId: string;
|
||||||
payChannel: PayChannel;
|
payChannel: PayChannel;
|
||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
|
recipientCharacterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateOrderSpy = (input: CreateOrderInput) => void;
|
export type CreateOrderSpy = (input: CreateOrderInput) => void;
|
||||||
|
|||||||
@@ -79,6 +79,27 @@ describe("payment order flow", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the selected tip recipient to order creation", async () => {
|
||||||
|
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||||
|
const actor = createActor(
|
||||||
|
createTestPaymentMachine({ createOrderSpy }),
|
||||||
|
).start();
|
||||||
|
await initialize(actor);
|
||||||
|
actor.send({
|
||||||
|
type: "PaymentCreateOrderSubmitted",
|
||||||
|
recipientCharacterId: "character_maya",
|
||||||
|
});
|
||||||
|
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||||
|
|
||||||
|
expect(createOrderSpy).toHaveBeenCalledWith({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
payChannel: "stripe",
|
||||||
|
autoRenew: true,
|
||||||
|
recipientCharacterId: "character_maya",
|
||||||
|
});
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("tracks order creation failures", async () => {
|
it("tracks order creation failures", async () => {
|
||||||
const createOrderFailed = vi
|
const createOrderFailed = vi
|
||||||
.spyOn(behaviorAnalytics, "createOrderFailed")
|
.spyOn(behaviorAnalytics, "createOrderFailed")
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ import { Result } from "@/utils/result";
|
|||||||
|
|
||||||
export const createPaymentOrderActor = fromPromise<
|
export const createPaymentOrderActor = fromPromise<
|
||||||
CreatePaymentOrderResponse,
|
CreatePaymentOrderResponse,
|
||||||
{ planId: string; payChannel: PayChannel; autoRenew: boolean }
|
{
|
||||||
|
planId: string;
|
||||||
|
payChannel: PayChannel;
|
||||||
|
autoRenew: boolean;
|
||||||
|
recipientCharacterId?: string;
|
||||||
|
}
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const result = await getPaymentRepository().createOrder(
|
const result = await getPaymentRepository().createOrder(
|
||||||
input.planId,
|
input.planId,
|
||||||
input.payChannel,
|
input.payChannel,
|
||||||
input.autoRenew,
|
input.autoRenew,
|
||||||
|
input.recipientCharacterId,
|
||||||
);
|
);
|
||||||
if (Result.isErr(result)) throw result.error;
|
if (Result.isErr(result)) throw result.error;
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
@@ -159,10 +159,14 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
|
|||||||
entry: "trackCreateOrderStart",
|
entry: "trackCreateOrderStart",
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "createOrder",
|
src: "createOrder",
|
||||||
input: ({ context }) => ({
|
input: ({ context, event }) => ({
|
||||||
planId: context.selectedPlanId,
|
planId: context.selectedPlanId,
|
||||||
payChannel: context.payChannel,
|
payChannel: context.payChannel,
|
||||||
autoRenew: context.autoRenew,
|
autoRenew: context.autoRenew,
|
||||||
|
...(event.type === "PaymentCreateOrderSubmitted" &&
|
||||||
|
event.recipientCharacterId
|
||||||
|
? { recipientCharacterId: event.recipientCharacterId }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "pollingOrder",
|
target: "pollingOrder",
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export type PaymentEvent =
|
|||||||
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
|
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
|
||||||
| { type: "PaymentAutoRenewChanged"; autoRenew: boolean }
|
| { type: "PaymentAutoRenewChanged"; autoRenew: boolean }
|
||||||
| { type: "PaymentAgreementChanged"; agreed: boolean }
|
| { type: "PaymentAgreementChanged"; agreed: boolean }
|
||||||
| { type: "PaymentCreateOrderSubmitted" }
|
| {
|
||||||
|
type: "PaymentCreateOrderSubmitted";
|
||||||
|
recipientCharacterId?: string;
|
||||||
|
}
|
||||||
| { type: "PaymentReturned"; orderId: string; createdAt?: number }
|
| { type: "PaymentReturned"; orderId: string; createdAt?: number }
|
||||||
| { type: "PaymentLaunchFailed"; errorMessage: string }
|
| { type: "PaymentLaunchFailed"; errorMessage: string }
|
||||||
| { type: "PaymentFirstRechargeConsumed" }
|
| { type: "PaymentFirstRechargeConsumed" }
|
||||||
|
|||||||