Compare commits
44 Commits
dev
...
dae04f75dc
| Author | SHA1 | Date | |
|---|---|---|---|
| dae04f75dc | |||
| 8f8e067d82 | |||
| 3c74d30189 | |||
| 4cbdd0da7c | |||
| e071f83474 | |||
| 3536045794 | |||
| 74b7eae18b | |||
| 59e4eac736 | |||
| 2e402de15b | |||
| a530850039 | |||
| a2cf62f78b | |||
| 67f292353e | |||
| 8c0d1ad3ce | |||
| 019caae598 | |||
| 73e6f341fb | |||
| d6f6bc2d87 | |||
| b34d3a3a67 | |||
| 9602fdd94d | |||
| bb1f0d225c | |||
| 0d5b5c17fa | |||
| 30ab2c2c97 | |||
| 17236bd14e | |||
| 5e0361a199 | |||
| 8660fe7b7e | |||
| a20a333665 | |||
| 92768047e9 | |||
| 4dae805a88 | |||
| 02f6964484 | |||
| 79397af739 | |||
| 770ce6b8fd | |||
| 0357fbcaff | |||
| 30122a44db | |||
| 2bb3da829e | |||
| fd2291fa62 | |||
| 36e5dd3c96 | |||
| 7103652829 | |||
| dff112eadc | |||
| 437ac3460b | |||
| 35939e7681 | |||
| a39d3c25ce | |||
| a317a46e87 | |||
| b531b02a85 | |||
| adfb230908 | |||
| 291f268cce |
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- test
|
- pre
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
DEPLOY_ENV="prod"
|
DEPLOY_ENV="prod"
|
||||||
NEXT_ENV_FILE=".env.production"
|
NEXT_ENV_FILE=".env.production"
|
||||||
;;
|
;;
|
||||||
test)
|
pre)
|
||||||
DEPLOY_ENV="test"
|
DEPLOY_ENV="test"
|
||||||
NEXT_ENV_FILE=".env.local"
|
NEXT_ENV_FILE=".env.local"
|
||||||
;;
|
;;
|
||||||
@@ -152,12 +152,9 @@ jobs:
|
|||||||
- name: Deploy image over SSH
|
- name: Deploy image over SSH
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
TEST_DEPLOY_DIR: ${{ secrets.TEST_DEPLOY_DIR }}
|
|
||||||
PROD_DEPLOY_DIR: ${{ secrets.PROD_DEPLOY_DIR }}
|
|
||||||
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
|
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
@@ -166,16 +163,18 @@ jobs:
|
|||||||
|
|
||||||
case "$DEPLOY_ENV" in
|
case "$DEPLOY_ENV" in
|
||||||
test)
|
test)
|
||||||
DEPLOY_DIR="${TEST_DEPLOY_DIR:-/opt/cozsweet-web-test}"
|
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-test"
|
||||||
HOST_PORT="9135"
|
HOST_PORT="9135"
|
||||||
CONTAINER_NAME="cozsweet-web-test"
|
CONTAINER_NAME="cozsweet-web-test"
|
||||||
PROJECT_NAME="cozsweet-web-test"
|
PROJECT_NAME="cozsweet-web-test"
|
||||||
|
SERVER_RETAIN_COUNT="1"
|
||||||
;;
|
;;
|
||||||
prod)
|
prod)
|
||||||
DEPLOY_DIR="${PROD_DEPLOY_DIR:-/opt/cozsweet-web-prod}"
|
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-prod"
|
||||||
HOST_PORT="9185"
|
HOST_PORT="9185"
|
||||||
CONTAINER_NAME="cozsweet-web-prod"
|
CONTAINER_NAME="cozsweet-web-prod"
|
||||||
PROJECT_NAME="cozsweet-web-prod"
|
PROJECT_NAME="cozsweet-web-prod"
|
||||||
|
SERVER_RETAIN_COUNT="2"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unsupported deploy env: $DEPLOY_ENV" >&2
|
echo "Unsupported deploy env: $DEPLOY_ENV" >&2
|
||||||
@@ -183,8 +182,11 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
DEPLOY_DIR="$DEPLOY_ROOT/current"
|
||||||
|
DEPLOY_SHARED_DIR="$DEPLOY_ROOT/shared"
|
||||||
|
REMOTE_ENV_FILE="$DEPLOY_SHARED_DIR/$NEXT_ENV_FILE"
|
||||||
DEPLOY_HOST="$SSH_HOST"
|
DEPLOY_HOST="$SSH_HOST"
|
||||||
DEPLOY_USER="$SSH_USER"
|
DEPLOY_USER="lzf"
|
||||||
DEPLOY_PORT="${SSH_PORT:-22}"
|
DEPLOY_PORT="${SSH_PORT:-22}"
|
||||||
DEPLOY_KEY="$SSH_PRIVATE_KEY"
|
DEPLOY_KEY="$SSH_PRIVATE_KEY"
|
||||||
DEPLOY_KNOWN_HOSTS="$SSH_KNOWN_HOSTS"
|
DEPLOY_KNOWN_HOSTS="$SSH_KNOWN_HOSTS"
|
||||||
@@ -210,19 +212,19 @@ jobs:
|
|||||||
SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o IdentitiesOnly=yes"
|
SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o IdentitiesOnly=yes"
|
||||||
SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o IdentitiesOnly=yes"
|
SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o IdentitiesOnly=yes"
|
||||||
|
|
||||||
ssh $SSH_OPTS "$SSH_TARGET" "mkdir -p '$DEPLOY_DIR'"
|
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
|
"mkdir -p '$DEPLOY_DIR' '$DEPLOY_SHARED_DIR' '$DEPLOY_ROOT/releases' '$DEPLOY_ROOT/backups' '$DEPLOY_ROOT/tmp' && chmod 700 '$DEPLOY_SHARED_DIR'"
|
||||||
scp $SCP_OPTS docker-compose.yml "$SSH_TARGET:$DEPLOY_DIR/docker-compose.yml"
|
scp $SCP_OPTS docker-compose.yml "$SSH_TARGET:$DEPLOY_DIR/docker-compose.yml"
|
||||||
scp $SCP_OPTS scripts/server/deploy-docker-image.sh "$SSH_TARGET:$DEPLOY_DIR/deploy-docker-image.sh"
|
scp $SCP_OPTS scripts/server/deploy-docker-image.sh "$SSH_TARGET:$DEPLOY_DIR/deploy-docker-image.sh"
|
||||||
scp $SCP_OPTS "$NEXT_ENV_FILE" "$SSH_TARGET:$DEPLOY_DIR/$NEXT_ENV_FILE"
|
scp $SCP_OPTS "$NEXT_ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_FILE"
|
||||||
|
ssh $SSH_OPTS "$SSH_TARGET" "chmod 600 '$REMOTE_ENV_FILE'"
|
||||||
|
|
||||||
printf '%s' "$REGISTRY_PASSWORD" \
|
printf '%s' "$REGISTRY_PASSWORD" \
|
||||||
| ssh $SSH_OPTS "$SSH_TARGET" \
|
| ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
"docker login '$REGISTRY_HOST' -u '$REGISTRY_USERNAME' --password-stdin"
|
"docker login '$REGISTRY_HOST' -u '$REGISTRY_USERNAME' --password-stdin"
|
||||||
|
|
||||||
SERVER_RETAIN_COUNT="1"
|
|
||||||
|
|
||||||
ssh $SSH_OPTS "$SSH_TARGET" \
|
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
"cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$NEXT_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME' '$SERVER_RETAIN_COUNT'"
|
"cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$REMOTE_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME' '$SERVER_RETAIN_COUNT'"
|
||||||
|
|
||||||
- name: Purge Cloudflare cache
|
- name: Purge Cloudflare cache
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ copy_env_by_branch() {
|
|||||||
START_PORT=9185
|
START_PORT=9185
|
||||||
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
test)
|
pre)
|
||||||
cp -f env-example/.env.local.example .env.local
|
cp -f env-example/.env.local.example .env.local
|
||||||
ENV_FILE=".env.local"
|
ENV_FILE=".env.local"
|
||||||
DEPLOY_ENV="test"
|
DEPLOY_ENV="test"
|
||||||
START_PORT=9135
|
START_PORT=9135
|
||||||
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.local (pre branch → test runtime) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
dev)
|
dev)
|
||||||
cp -f env-example/.env.development.example .env.local
|
cp -f env-example/.env.development.example .env.local
|
||||||
|
|||||||
@@ -25,9 +25,9 @@
|
|||||||
"./src/data/schemas/payment",
|
"./src/data/schemas/payment",
|
||||||
"./src/data/schemas/payment/request",
|
"./src/data/schemas/payment/request",
|
||||||
"./src/data/schemas/payment/response",
|
"./src/data/schemas/payment/response",
|
||||||
"./src/data/schemas/private-zoom",
|
"./src/data/schemas/private-zone",
|
||||||
"./src/data/schemas/private-zoom/request",
|
"./src/data/schemas/private-zone/request",
|
||||||
"./src/data/schemas/private-zoom/response",
|
"./src/data/schemas/private-zone/response",
|
||||||
"./src/data/schemas/user",
|
"./src/data/schemas/user",
|
||||||
"./src/data/storage/app",
|
"./src/data/storage/app",
|
||||||
"./src/data/storage/auth",
|
"./src/data/storage/auth",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
| 预发环境 | `test` | `pre` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
||||||
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
||||||
|
|
||||||
当前 remote 配置示例:
|
当前 remote 配置示例:
|
||||||
@@ -26,15 +26,15 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
|||||||
|
|
||||||
## 本地发布流程
|
## 本地发布流程
|
||||||
|
|
||||||
旧测试环境发布流程(脚本迁移前):
|
旧预发环境发布流程(脚本迁移前):
|
||||||
|
|
||||||
1. 进入 test worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test`
|
1. 进入 pre worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre`
|
||||||
2. 确认当前分支。
|
2. 确认当前分支。
|
||||||
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。
|
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `pre` 分支。
|
||||||
4. 复制测试环境图标到 `public/`。
|
4. 复制测试环境图标到 `public/`。
|
||||||
5. 复制 `env-example/.env.local.example` 到 `.env.local`。
|
5. 复制 `env-example/.env.local.example` 到 `.env.local`。
|
||||||
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。
|
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。
|
||||||
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。
|
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `pre` 分支。
|
||||||
8. 推送成功后尝试清除 Cloudflare CDN 缓存。
|
8. 推送成功后尝试清除 Cloudflare CDN 缓存。
|
||||||
|
|
||||||
旧生产环境发布流程(脚本迁移前):
|
旧生产环境发布流程(脚本迁移前):
|
||||||
@@ -71,7 +71,7 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
|||||||
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
|
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
|
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
|
||||||
| `test` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
| `pre` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
||||||
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
|
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
|
||||||
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
||||||
|
|
||||||
@@ -264,12 +264,12 @@ ABORTED ... (docker compose up failed)
|
|||||||
|
|
||||||
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
|
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
|
||||||
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
||||||
3. `main` 分支对应生产环境,`test` 分支对应测试环境。
|
3. `main` 分支对应生产环境,`pre` 分支对应预发环境。
|
||||||
4. 生产推送当前使用 `git push --force production main`。
|
4. 生产推送当前使用 `git push --force production main`。
|
||||||
5. 构建失败时不会重启容器,旧容器继续运行。
|
5. 构建失败时不会重启容器,旧容器继续运行。
|
||||||
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
||||||
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
||||||
8. 测试环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
8. 预发环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
||||||
|
|
||||||
## 后续优化建议
|
## 后续优化建议
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
2. `isActive=true`;
|
2. `isActive=true`;
|
||||||
3. `capabilities.chat=true`。
|
3. `capabilities.chat=true`。
|
||||||
|
|
||||||
服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateZoom` 只有在本地能力和服务端 `privateContent` 同时开启时可用。
|
服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateZone` 只有在本地能力和服务端 `privateContent` 同时开启时可用。
|
||||||
|
|
||||||
远端目录返回前,生产环境只使用 Elio 作为临时目录;非生产环境可使用完整本地目录。远端目录加载成功后,以合并结果为准。
|
远端目录返回前,生产环境只使用 Elio 作为临时目录;非生产环境可使用完整本地目录。远端目录加载成功后,以合并结果为准。
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
```text
|
```text
|
||||||
/characters/{slug}/splash
|
/characters/{slug}/splash
|
||||||
/characters/{slug}/chat
|
/characters/{slug}/chat
|
||||||
/characters/{slug}/private-zoom
|
/characters/{slug}/private-zone
|
||||||
/characters/{slug}/tip
|
/characters/{slug}/tip
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,11 +81,11 @@ Authorization: Bearer <TOKEN>
|
|||||||
```text
|
```text
|
||||||
/splash -> /characters/elio/splash
|
/splash -> /characters/elio/splash
|
||||||
/chat -> /characters/elio/chat
|
/chat -> /characters/elio/chat
|
||||||
/private-zoom -> /characters/elio/private-zoom
|
/private-zone -> /characters/elio/private-zone
|
||||||
/tip -> /characters/elio/tip
|
/tip -> /characters/elio/tip
|
||||||
```
|
```
|
||||||
|
|
||||||
Chat Provider 以 `characterId` 为边界,路由角色变化时创建新的 Actor。Private Zoom 的角色边界由 [Private Zoom 权威协议](./FRONTEND_PRIVATE_ZOOM_API.md) 定义。
|
Chat Provider 以 `characterId` 为边界,路由角色变化时创建新的 Actor。Private Zone 的角色边界由 [Private Zone 权威协议](./FRONTEND_PRIVATE_ZONE_API.md) 定义。
|
||||||
|
|
||||||
## 3. Chat HTTP 协议
|
## 3. Chat HTTP 协议
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@ conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)}
|
|||||||
|
|
||||||
## 7. 关联业务边界
|
## 7. 关联业务边界
|
||||||
|
|
||||||
- Private Zoom 的相册、解锁、Gallery 和支付回跳由 [Private Zoom 权威协议](./FRONTEND_PRIVATE_ZOOM_API.md) 定义。
|
- Private Zone 的相册、解锁、Gallery 和支付回跳由 [Private Zone 权威协议](./FRONTEND_PRIVATE_ZONE_API.md) 定义。
|
||||||
- Tip 的角色归属、订单轮询和支付回跳由 [Payment 权威协议](./FRONTEND_PAYMENT_API.md) 定义;Chat 只保存解锁所需的原角色回跳地址。
|
- Tip 的角色归属、订单轮询和支付回跳由 [Payment 权威协议](./FRONTEND_PAYMENT_API.md) 定义;Chat 只保存解锁所需的原角色回跳地址。
|
||||||
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
||||||
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ payChannel = ezpay
|
|||||||
subscriptionType = vip | topup | tip
|
subscriptionType = vip | topup | tip
|
||||||
giftCategory(仅 Tip,可空)
|
giftCategory(仅 Tip,可空)
|
||||||
giftPlanId(仅 Tip,可空)
|
giftPlanId(仅 Tip,可空)
|
||||||
returnTo = chat | private-zoom | profile(可选)
|
returnTo = chat | private-zone | profile(可选)
|
||||||
characterSlug(可选)
|
characterSlug(可选)
|
||||||
createdAt
|
createdAt
|
||||||
```
|
```
|
||||||
@@ -238,7 +238,7 @@ Tip -> /characters/{slug}/tip?category=...&planId=...&payChannel=ezpay&
|
|||||||
|
|
||||||
恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid、failed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品,不再读取 `coffee_type`。
|
恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid、failed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品,不再读取 `coffee_type`。
|
||||||
|
|
||||||
无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-zoom` 的最终页面行为由 [Private Zoom 权威协议](./FRONTEND_PRIVATE_ZOOM_API.md) 定义。
|
无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-zone` 的最终页面行为由 [Private Zone 权威协议](./FRONTEND_PRIVATE_ZONE_API.md) 定义。
|
||||||
|
|
||||||
## 8. 成功后的跨域同步
|
## 8. 成功后的跨域同步
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# CozSweet Private Zoom 权威协议
|
# CozSweet Private Zone 权威协议
|
||||||
|
|
||||||
## 1. 状态与范围
|
## 1. 状态与范围
|
||||||
|
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
| 边界 | 实现位置 |
|
| 边界 | 实现位置 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||||
| 请求与响应字段 | `src/data/schemas/private-zoom` |
|
| 请求与响应字段 | `src/data/schemas/private-zone` |
|
||||||
| API 与 Repository | `src/data/services/api/private_zoom_api.ts`、`src/data/repositories/private_zoom_repository.ts` |
|
| API 与 Repository | `src/data/services/api/private_zone_api.ts`、`src/data/repositories/private_zone_repository.ts` |
|
||||||
| Private Zoom 状态机 | `src/stores/private-zoom` |
|
| Private Zone 状态机 | `src/stores/private-zone` |
|
||||||
| 页面、Gallery 与导航 | `src/app/private-zoom` |
|
| 页面、Gallery 与导航 | `src/app/private-zone` |
|
||||||
| 角色 Provider | `src/providers/private-zoom-route-provider.tsx` |
|
| 角色 Provider | `src/providers/private-zone-route-provider.tsx` |
|
||||||
|
|
||||||
修改上述实现时必须在同一变更中更新本文,不能再新增按相册列表、解锁或 Gallery 拆分的并行协议。
|
修改上述实现时必须在同一变更中更新本文,不能再新增按相册列表、解锁或 Gallery 拆分的并行协议。
|
||||||
|
|
||||||
@@ -22,13 +22,13 @@
|
|||||||
标准路由:
|
标准路由:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/characters/{characterSlug}/private-zoom
|
/characters/{characterSlug}/private-zone
|
||||||
```
|
```
|
||||||
|
|
||||||
旧地址保留为默认角色重定向,并保留查询参数:
|
旧地址保留为默认角色重定向,并保留查询参数:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/private-zoom -> /characters/elio/private-zoom
|
/private-zone -> /characters/elio/private-zone
|
||||||
```
|
```
|
||||||
|
|
||||||
URL 使用角色 `slug`,API 和 Actor 使用角色业务 `id`:
|
URL 使用角色 `slug`,API 和 Actor 使用角色业务 `id`:
|
||||||
@@ -39,14 +39,14 @@ URL 使用角色 `slug`,API 和 Actor 使用角色业务 `id`:
|
|||||||
| `maya-tan` | `maya` |
|
| `maya-tan` | `maya` |
|
||||||
| `nayeli-cervantes` | `nayeli` |
|
| `nayeli-cervantes` | `nayeli` |
|
||||||
|
|
||||||
角色必须同时存在于本地目录且 `capabilities.privateZoom=true`。该能力由本地配置和角色目录响应的 `privateContent` 共同决定;能力关闭或 slug 未知时路由返回 Not Found。
|
角色必须同时存在于本地目录且 `capabilities.privateZone=true`。该能力由本地配置和角色目录响应的 `privateContent` 共同决定;能力关闭或 slug 未知时路由返回 Not Found。
|
||||||
|
|
||||||
`PrivateZoomProvider` 以 `characterId` 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态,不能复用上一角色的相册、余额或解锁请求。
|
`PrivateZoneProvider` 以 `characterId` 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态,不能复用上一角色的相册、余额或解锁请求。
|
||||||
|
|
||||||
## 3. 相册列表
|
## 3. 相册列表
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET <API_BASE_URL>/api/private-zoom/albums?characterId=maya-tan&limit=20
|
GET <API_BASE_URL>/api/private-zone/albums?characterId=maya-tan&limit=20
|
||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
| `characterId` | 是 | 当前角色业务 ID |
|
| `characterId` | 是 | 当前角色业务 ID |
|
||||||
| `limit` | 否 | 当前固定为 20 |
|
| `limit` | 否 | 当前固定为 20 |
|
||||||
|
|
||||||
前端当前只加载第一页,不实现 Private Zoom 分页,也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。
|
前端当前只加载第一页,不实现 Private Zone 分页,也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。
|
||||||
|
|
||||||
标准响应数据:
|
标准响应数据:
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ album.locked || !album.unlocked || album.lockDetail.locked
|
|||||||
用户点击锁定相册后,前端先展示确认 Dialog。只有待确认 `albumId` 仍存在于当前 Actor 的 `items` 中,才会发起请求。
|
用户点击锁定相册后,前端先展示确认 Dialog。只有待确认 `albumId` 仍存在于当前 Actor 的 `items` 中,才会发起请求。
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST <API_BASE_URL>/api/private-zoom/albums/{albumId}/unlock
|
POST <API_BASE_URL>/api/private-zone/albums/{albumId}/unlock
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
@@ -177,25 +177,25 @@ Schema 同时允许未知字符串,未知失败原因使用通用错误文案
|
|||||||
|
|
||||||
## 5. 身份与支付导航
|
## 5. 身份与支付导航
|
||||||
|
|
||||||
Private Zoom 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表;`notLoggedIn` 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。
|
Private Zone 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表;`notLoggedIn` 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。
|
||||||
|
|
||||||
积分不足生成 Paywall 请求后:
|
积分不足生成 Paywall 请求后:
|
||||||
|
|
||||||
| 当前身份 | 导航 |
|
| 当前身份 | 导航 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Guest 或 Not Logged In | 打开 Auth,redirect 为当前角色 Private Zoom |
|
| Guest 或 Not Logged In | 打开 Auth,redirect 为当前角色 Private Zone |
|
||||||
| 已认证用户 | 打开 Top-up,`returnTo=private-zoom`,保留当前角色来源 |
|
| 已认证用户 | 打开 Top-up,`returnTo=private-zone`,保留当前角色来源 |
|
||||||
|
|
||||||
Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复导航。支付回跳和订单恢复遵循 [Payment 权威协议](./FRONTEND_PAYMENT_API.md)。
|
Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复导航。支付回跳和订单恢复遵循 [Payment 权威协议](./FRONTEND_PAYMENT_API.md)。
|
||||||
|
|
||||||
解锁成功后 `unlockSuccessNonce` 递增,页面桥接到 `UserFetch`,刷新当前积分和权益。Private Zoom 不自行修改 User Store 余额。
|
解锁成功后 `unlockSuccessNonce` 递增,页面桥接到 `UserFetch`,刷新当前积分和权益。Private Zone 不自行修改 User Store 余额。
|
||||||
|
|
||||||
## 6. Gallery URL 协议
|
## 6. Gallery URL 协议
|
||||||
|
|
||||||
解锁相册使用查询参数打开页内 Gallery:
|
解锁相册使用查询参数打开页内 Gallery:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/characters/{slug}/private-zoom?album={albumId}&image={zeroBasedIndex}
|
/characters/{slug}/private-zone?album={albumId}&image={zeroBasedIndex}
|
||||||
```
|
```
|
||||||
|
|
||||||
解析规则:
|
解析规则:
|
||||||
@@ -208,7 +208,7 @@ Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复
|
|||||||
|
|
||||||
任一条件不满足时,页面通过 replace 删除 `album` 和 `image`,并保留其他查询参数。
|
任一条件不满足时,页面通过 replace 删除 `album` 和 `image`,并保留其他查询参数。
|
||||||
|
|
||||||
页面内点击九宫格缩略图时,Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Zoom。
|
页面内点击九宫格缩略图时,Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Zone。
|
||||||
|
|
||||||
Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image` 仍使用原始数组索引。横向拖动时图片跟随指针,达到视口宽度 18%(最低 56px),或达到 0.45px/ms 且至少移动 24px 时切换;首尾越界拖动使用 0.28 阻尼且不循环。松手后使用 240ms 横向吸附动画,键盘方向键和左右按钮复用相同切换逻辑。Escape 关闭;`prefers-reduced-motion` 下取消吸附过渡。
|
Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image` 仍使用原始数组索引。横向拖动时图片跟随指针,达到视口宽度 18%(最低 56px),或达到 0.45px/ms 且至少移动 24px 时切换;首尾越界拖动使用 0.28 阻尼且不循环。松手后使用 240ms 横向吸附动画,键盘方向键和左右按钮复用相同切换逻辑。Escape 关闭;`prefers-reduced-motion` 下取消吸附过渡。
|
||||||
|
|
||||||
@@ -222,17 +222,17 @@ Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image
|
|||||||
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
|
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
|
||||||
- Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图;
|
- Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图;
|
||||||
- `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益;
|
- `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益;
|
||||||
- Private Zoom 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存;
|
- Private Zone 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存;
|
||||||
- Private Zoom 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。
|
- Private Zone 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。
|
||||||
|
|
||||||
## 8. 变更验收
|
## 8. 变更验收
|
||||||
|
|
||||||
Private Zoom 协议相关变更至少验证:
|
Private Zone 协议相关变更至少验证:
|
||||||
|
|
||||||
1. `src/data/schemas/private-zoom/__tests__`;
|
1. `src/data/schemas/private-zone/__tests__`;
|
||||||
2. `src/data/services/api/__tests__/multi_character_api.test.ts` 中的 Private Zoom 请求;
|
2. `src/data/services/api/__tests__/multi_character_api.test.ts` 中的 Private Zone 请求;
|
||||||
3. `src/stores/private-zoom/__tests__`;
|
3. `src/stores/private-zone/__tests__`;
|
||||||
4. `src/app/private-zoom/__tests__` 与组件测试;
|
4. `src/app/private-zone/__tests__` 与组件测试;
|
||||||
5. Elio、Maya、Nayeli 列表互不串联;
|
5. Elio、Maya、Nayeli 列表互不串联;
|
||||||
6. 登录身份变化会刷新当前角色列表;
|
6. 登录身份变化会刷新当前角色列表;
|
||||||
7. 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支;
|
7. 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支;
|
||||||
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
| Workflow | 触发时机 | 职责 |
|
| Workflow | 触发时机 | 职责 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.gitea/workflows/ci.yml` | `dev`、`main`、`test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
| `.gitea/workflows/ci.yml` | `dev`、`main`、`pre` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
||||||
| `.gitea/workflows/docker-image.yml` | `main`、`test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
| `.gitea/workflows/docker-image.yml` | `main`、`pre` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
||||||
|
|
||||||
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
|
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
|
||||||
|
|
||||||
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`test`
|
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`pre`
|
||||||
分支为快速部署测试环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
分支用于快速部署预发环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
||||||
`ci.yml` 会同时执行完整检查并提供测试结果。
|
`ci.yml` 会同时执行完整检查并提供测试结果。
|
||||||
|
|
||||||
## 必需 Secrets
|
## 必需 Secrets
|
||||||
@@ -39,7 +39,7 @@ Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环
|
|||||||
|
|
||||||
## 镜像 Tag 规则
|
## 镜像 Tag 规则
|
||||||
|
|
||||||
`test` 分支发布:
|
`pre` 分支发布时继续使用现有 `test-*` 运行镜像标签:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
REGISTRY_IMAGE:test-<short_sha>
|
REGISTRY_IMAGE:test-<short_sha>
|
||||||
@@ -81,7 +81,7 @@ volumes:
|
|||||||
|
|
||||||
## 验证方式
|
## 验证方式
|
||||||
|
|
||||||
推送到 `test` 分支后,检查 Actions 日志中是否出现:
|
推送到 `pre` 分支后,检查 Actions 日志中是否出现:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Build Docker image
|
Build Docker image
|
||||||
@@ -124,4 +124,4 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod-<short_sha>
|
|||||||
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
|
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
|
||||||
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
|
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
|
||||||
|
|
||||||
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;测试环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;预发环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ https://<APP_HOST>/external-entry?<参数>
|
|||||||
|
|
||||||
| 参数 | 可选值或格式 | 用途 |
|
| 参数 | 可选值或格式 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `target` | `chat`、`tip`、`private-zoom` | 指定进入的页面;不传或值无效时进入聊天页。 |
|
| `target` | `chat`、`tip`、`private-zone` | 指定进入的页面;不传或值无效时进入聊天页。历史值 `private-room` 兼容进入 `private-zone`,新链接不得继续使用。 |
|
||||||
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。 |
|
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。 |
|
||||||
| `psid` | string | 传入 Facebook Page-scoped User ID。 |
|
| `psid` | string | 传入 Facebook Page-scoped User ID。 |
|
||||||
| `mode` | `promotion` | 开启聊天促销模式。 |
|
| `mode` | `promotion` | 开启聊天促销模式。 |
|
||||||
@@ -27,6 +27,8 @@ https://<APP_HOST>/external-entry?<参数>
|
|||||||
|
|
||||||
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。
|
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。
|
||||||
|
|
||||||
|
标准私密空间参数是 `target=private-zone`。前端仅为已经发布的旧链接保留 `target=private-room` 兼容;`private_zone` 等其他拼写仍按无效目标处理并回退聊天页。
|
||||||
|
|
||||||
## PSID 与登录状态流程
|
## PSID 与登录状态流程
|
||||||
|
|
||||||
PSID 保存、登录状态判断和 Facebook Identity 绑定逻辑见 [PSID 与登录状态流程图](./psid-login-flow.md)。
|
PSID 保存、登录状态判断和 Facebook Identity 绑定逻辑见 [PSID 与登录状态流程图](./psid-login-flow.md)。
|
||||||
@@ -78,13 +80,13 @@ https://<APP_HOST>/external-entry?target=tip
|
|||||||
私密空间:
|
私密空间:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://<APP_HOST>/external-entry?target=private-zoom
|
https://<APP_HOST>/external-entry?target=private-zone
|
||||||
```
|
```
|
||||||
|
|
||||||
进入 Nayeli 私密空间:
|
进入 Nayeli 私密空间:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://<APP_HOST>/external-entry?target=private-zoom&character=nayeli
|
https://<APP_HOST>/external-entry?target=private-zone&character=nayeli
|
||||||
```
|
```
|
||||||
|
|
||||||
`psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。
|
`psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
|
|
||||||
| 角色 | 链接 |
|
| 角色 | 链接 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Elio | [打开 Elio 私密空间](http://localhost:3000/external-entry?target=private-zoom&character=elio) |
|
| Elio | [打开 Elio 私密空间](http://localhost:3000/external-entry?target=private-zone&character=elio) |
|
||||||
| Maya | [打开 Maya 私密空间](http://localhost:3000/external-entry?target=private-zoom&character=maya) |
|
| Maya | [打开 Maya 私密空间](http://localhost:3000/external-entry?target=private-zone&character=maya) |
|
||||||
| Nayeli | [打开 Nayeli 私密空间](http://localhost:3000/external-entry?target=private-zoom&character=nayeli) |
|
| Nayeli | [打开 Nayeli 私密空间](http://localhost:3000/external-entry?target=private-zone&character=nayeli) |
|
||||||
|
|
||||||
如需为其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。对于不带查询参数的默认入口,请改为追加 `?psid=27511427698460020`。
|
如需为其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。对于不带查询参数的默认入口,请改为追加 `?psid=27511427698460020`。
|
||||||
|
|||||||
@@ -1,43 +1,66 @@
|
|||||||
# 外部入口链接清单
|
# 外部入口链接清单
|
||||||
|
|
||||||
[返回外部入口接入说明](./README.md)
|
这份清单可以独立发送和直接点击。所有新链接统一使用标准参数 `target=private-zone`;历史参数 `target=private-room` 仅用于兼容已经发出的旧链接。
|
||||||
|
|
||||||
以下链接可以直接点击打开。PSID 示例统一使用 `27511427698460020`。
|
## 使用规则
|
||||||
|
|
||||||
|
- `target=chat`:进入聊天页。
|
||||||
|
- `target=tip`:进入对应角色的打赏页。
|
||||||
|
- `target=private-zone`:进入对应角色的私密空间。
|
||||||
|
- `character` 可使用 `elio`、`maya`、`nayeli`;不传或值无效时使用 Elio。
|
||||||
|
- `mode=promotion` 只在 `target=chat` 且 `promotion_type` 为 `voice`、`image` 或 `private` 时生效。
|
||||||
|
- PSID 示例统一使用 `27511427698460020`;不要在入口中放登录 Token、Page Access Token 或 App Secret。
|
||||||
|
|
||||||
## 测试环境
|
## 测试环境
|
||||||
|
|
||||||
| 入口 | 链接 |
|
| 入口 | 可点击链接 | 预期落地页面 |
|
||||||
| --- | --- |
|
| --- | --- | --- |
|
||||||
| 普通聊天 | [打开普通聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat) |
|
| 普通聊天(Elio) | [打开普通聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat) | `/characters/elio/chat` |
|
||||||
| Maya 聊天 | [打开 Maya 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=maya) |
|
| Maya 聊天 | [打开 Maya 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=maya) | `/characters/maya/chat` |
|
||||||
| Nayeli 聊天 | [打开 Nayeli 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=nayeli) |
|
| Nayeli 聊天 | [打开 Nayeli 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=nayeli) | `/characters/nayeli/chat` |
|
||||||
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](https://frontend-test.banlv-ai.com/external-entry?target=chat&psid=27511427698460020) |
|
| 携带 PSID 的 Elio 聊天 | [打开 PSID 聊天示例](https://frontend-test.banlv-ai.com/external-entry?target=chat&psid=27511427698460020) | `/characters/elio/chat` |
|
||||||
| 语音促销 | [打开语音促销](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) | `/characters/elio/chat`,保存语音促销意图 |
|
||||||
| 图片促销 | [打开图片促销](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) | `/characters/elio/chat`,保存图片促销意图 |
|
||||||
| 私密文本促销 | [打开私密文本促销](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) | `/characters/elio/chat`,保存私密文本促销意图 |
|
||||||
| 咖啡打赏 | [打开咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip) |
|
| Elio 咖啡打赏 | [打开 Elio 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=elio) | `/characters/elio/tip` |
|
||||||
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=maya) |
|
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=maya) | `/characters/maya/tip` |
|
||||||
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=nayeli) |
|
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=nayeli) | `/characters/nayeli/tip` |
|
||||||
| 私密空间 | [打开私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zoom) |
|
| Elio 私密空间 | [打开 Elio 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=elio) | `/characters/elio/private-zone` |
|
||||||
| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zoom&character=maya) |
|
| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=maya) | `/characters/maya/private-zone` |
|
||||||
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zoom&character=nayeli) |
|
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||||
|
|
||||||
|
## 旧链接兼容验证
|
||||||
|
|
||||||
|
以下链接仅用于验证已经发出的 `private-room` 旧链接仍能正确进入私密空间。新投放链接不要再使用这些地址。
|
||||||
|
|
||||||
|
| 旧入口 | 可点击链接 | 预期落地页面 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Elio 旧私密空间 | [验证 Elio 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=elio) | `/characters/elio/private-zone` |
|
||||||
|
| Maya 旧私密空间 | [验证 Maya 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=maya) | `/characters/maya/private-zone` |
|
||||||
|
| Nayeli 旧私密空间 | [验证 Nayeli 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||||
|
|
||||||
## 正式环境
|
## 正式环境
|
||||||
|
|
||||||
| 入口 | 链接 |
|
正式环境仅列标准链接,不再传播 `private-room` 旧参数。
|
||||||
| --- | --- |
|
|
||||||
| 普通聊天 | [打开普通聊天](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) |
|
|
||||||
| 语音促销 | [打开语音促销](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=private) |
|
|
||||||
| 咖啡打赏 | [打开咖啡打赏](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-zoom) |
|
|
||||||
| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-zoom&character=maya) |
|
|
||||||
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-zoom&character=nayeli) |
|
|
||||||
|
|
||||||
如需在其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。
|
| 入口 | 可点击链接 | 预期落地页面 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 普通聊天(Elio) | [打开普通聊天](https://cozsweet.com/external-entry?target=chat) | `/characters/elio/chat` |
|
||||||
|
| Maya 聊天 | [打开 Maya 聊天](https://cozsweet.com/external-entry?target=chat&character=maya) | `/characters/maya/chat` |
|
||||||
|
| Nayeli 聊天 | [打开 Nayeli 聊天](https://cozsweet.com/external-entry?target=chat&character=nayeli) | `/characters/nayeli/chat` |
|
||||||
|
| 携带 PSID 的 Elio 聊天 | [打开 PSID 聊天示例](https://cozsweet.com/external-entry?target=chat&psid=27511427698460020) | `/characters/elio/chat` |
|
||||||
|
| 语音促销 | [打开语音促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=voice) | `/characters/elio/chat`,保存语音促销意图 |
|
||||||
|
| 图片促销 | [打开图片促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=image) | `/characters/elio/chat`,保存图片促销意图 |
|
||||||
|
| 私密文本促销 | [打开私密文本促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=private) | `/characters/elio/chat`,保存私密文本促销意图 |
|
||||||
|
| Elio 咖啡打赏 | [打开 Elio 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=elio) | `/characters/elio/tip` |
|
||||||
|
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=maya) | `/characters/maya/tip` |
|
||||||
|
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=nayeli) | `/characters/nayeli/tip` |
|
||||||
|
| Elio 私密空间 | [打开 Elio 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=elio) | `/characters/elio/private-zone` |
|
||||||
|
| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=maya) | `/characters/maya/private-zone` |
|
||||||
|
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||||
|
|
||||||
|
## 验收判断
|
||||||
|
|
||||||
|
打开入口后,浏览器地址最终应与“预期落地页面”一致。普通聊天和私密空间必须分别落在 `/chat` 与 `/private-zone`,不能再进入同一个页面。
|
||||||
|
|
||||||
|
如需在其他入口携带 PSID,在已有查询参数的链接末尾追加 `&psid=27511427698460020`。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Private Zoom 全链路改名联调说明
|
# Private Zone 全链路改名联调说明
|
||||||
|
|
||||||
- 日期:2026-07-22
|
- 日期:2026-07-22
|
||||||
- 目标环境:本地、`pre`、生产
|
- 目标环境:本地、`pre`、生产
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
原功能名称存在拼写错误。本次将前端、unified 后端、Manager、数据库、埋点和文档中的正式命名统一为 `Private Zoom`,并同步切换页面路由、API 路径和公开枚举值。
|
原功能名称存在拼写错误。本次将前端、unified 后端、Manager、数据库、埋点和文档中的正式命名统一为 `Private Zone`,并同步切换页面路由、API 路径和公开枚举值。
|
||||||
|
|
||||||
## 2. 环境地址
|
## 2. 环境地址
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@
|
|||||||
|
|
||||||
前端已完成以下修改:
|
前端已完成以下修改:
|
||||||
|
|
||||||
- 页面路由统一为 `/private-zoom` 和 `/characters/{characterSlug}/private-zoom`。
|
- 页面路由统一为 `/private-zone` 和 `/characters/{characterSlug}/private-zone`。
|
||||||
- 外部入口参数统一为 `target=private-zoom`。
|
- 外部入口参数统一为 `target=private-zone`。
|
||||||
- 支付回跳参数统一为 `returnTo=private-zoom`。
|
- 支付回跳参数统一为 `returnTo=private-zone`。
|
||||||
- 类型、Repository、API client、XState actor、Provider 和资源目录统一使用 `privateZoom`、`PrivateZoom`、`private-zoom` 或 `private_zoom`。
|
- 类型、Repository、API client、XState actor、Provider 和资源目录统一使用 `privateZone`、`PrivateZone`、`private-zone` 或 `private_zone`。
|
||||||
- 埋点键统一为 `navigation.private_zoom`、`chat.open_private_zoom_from_avatar` 等新名称。
|
- 埋点键统一为 `navigation.private_zone`、`chat.open_private_zone_from_avatar` 等新名称。
|
||||||
- 角色能力字段统一为 `capabilities.privateZoom`。
|
- 角色能力字段统一为 `capabilities.privateZone`。
|
||||||
|
|
||||||
旧页面和旧查询参数不再作为正式入口保留。发布时必须让前后端属于同一个发布批次。
|
旧页面和旧查询参数不再作为正式入口保留。发布时必须让前后端属于同一个发布批次。
|
||||||
|
|
||||||
@@ -34,19 +34,19 @@
|
|||||||
|
|
||||||
| 方法 | 新路径 | 用途 |
|
| 方法 | 新路径 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET` | `/api/private-zoom/albums` | 查询付费图片包 |
|
| `GET` | `/api/private-zone/albums` | 查询付费图片包 |
|
||||||
| `POST` | `/api/private-zoom/albums/{albumId}/unlock` | 解锁图片包 |
|
| `POST` | `/api/private-zone/albums/{albumId}/unlock` | 解锁图片包 |
|
||||||
| `GET` | `/api/private-zoom/moments` | 查询朋友圈式内容 |
|
| `GET` | `/api/private-zone/moments` | 查询朋友圈式内容 |
|
||||||
| `POST` | `/api/private-zoom/moments/{momentId}/unlock` | 解锁单条内容 |
|
| `POST` | `/api/private-zone/moments/{momentId}/unlock` | 解锁单条内容 |
|
||||||
| `GET` | `/api/private-zoom/config` | 查询当前角色和用户解锁配置 |
|
| `GET` | `/api/private-zone/config` | 查询当前角色和用户解锁配置 |
|
||||||
| `GET` | `/api/private-zoom/diaries` | 查询关系日记 |
|
| `GET` | `/api/private-zone/diaries` | 查询关系日记 |
|
||||||
| `POST` | `/api/private-zoom/diaries/{diaryId}/seen` | 标记关系日记已读 |
|
| `POST` | `/api/private-zone/diaries/{diaryId}/seen` | 标记关系日记已读 |
|
||||||
| `POST` | `/api/private-zoom/diaries/{diaryId}/unlock` | 解锁关系日记 |
|
| `POST` | `/api/private-zone/diaries/{diaryId}/unlock` | 解锁关系日记 |
|
||||||
|
|
||||||
### 4.1 图片包列表
|
### 4.1 图片包列表
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/private-zoom/albums?characterId=elio&limit=20
|
GET /api/private-zone/albums?characterId=elio&limit=20
|
||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -59,14 +59,14 @@ Authorization: Bearer <TOKEN>
|
|||||||
调用示例:
|
调用示例:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl 'https://proapi.banlv-ai.com/api/private-zoom/albums?characterId=elio&limit=20' \
|
curl 'https://proapi.banlv-ai.com/api/private-zone/albums?characterId=elio&limit=20' \
|
||||||
-H 'Authorization: Bearer <TOKEN>'
|
-H 'Authorization: Bearer <TOKEN>'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 解锁图片包
|
### 4.2 解锁图片包
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/private-zoom/albums/{albumId}/unlock
|
POST /api/private-zone/albums/{albumId}/unlock
|
||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
@@ -77,7 +77,7 @@ Content-Type: application/json
|
|||||||
| `expectedCost` | body | integer | 否 | 是 | 前端确认价格;与后端价格不一致时拒绝解锁 |
|
| `expectedCost` | body | integer | 否 | 是 | 前端确认价格;与后端价格不一致时拒绝解锁 |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST 'https://proapi.banlv-ai.com/api/private-zoom/albums/<ALBUM_ID>/unlock' \
|
curl -X POST 'https://proapi.banlv-ai.com/api/private-zone/albums/<ALBUM_ID>/unlock' \
|
||||||
-H 'Authorization: Bearer <TOKEN>' \
|
-H 'Authorization: Bearer <TOKEN>' \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"expectedCost":320}'
|
-d '{"expectedCost":320}'
|
||||||
@@ -86,8 +86,8 @@ curl -X POST 'https://proapi.banlv-ai.com/api/private-zoom/albums/<ALBUM_ID>/unl
|
|||||||
### 4.3 Moments 内容与解锁
|
### 4.3 Moments 内容与解锁
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/private-zoom/moments?characterId=elio&limit=20
|
GET /api/private-zone/moments?characterId=elio&limit=20
|
||||||
POST /api/private-zoom/moments/{momentId}/unlock
|
POST /api/private-zone/moments/{momentId}/unlock
|
||||||
```
|
```
|
||||||
|
|
||||||
解锁请求体与图片包一致,`expectedCost` 为可选整数。响应中的正式分类值同步改为:
|
解锁请求体与图片包一致,`expectedCost` 为可选整数。响应中的正式分类值同步改为:
|
||||||
@@ -95,8 +95,8 @@ POST /api/private-zoom/moments/{momentId}/unlock
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"lockDetail": {
|
"lockDetail": {
|
||||||
"type": "private_zoom_moment",
|
"type": "private_zone_moment",
|
||||||
"reason": "private_zoom_moment"
|
"reason": "private_zone_moment"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -105,24 +105,24 @@ POST /api/private-zoom/moments/{momentId}/unlock
|
|||||||
|
|
||||||
| 使用位置 | 新值 | 类型 |
|
| 使用位置 | 新值 | 类型 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/api/chat/unlock-private` 的 `lockType` 别名 | `private_zoom` | string enum |
|
| `/api/chat/unlock-private` 的 `lockType` 别名 | `private_zone` | string enum |
|
||||||
| 日程导入 `contentType` 别名 | `private_zoom` | string enum,归一化为 `paid_content` |
|
| 日程导入 `contentType` 别名 | `private_zone` | string enum,归一化为 `paid_content` |
|
||||||
| 外部入口 `target` | `private-zoom` | string enum |
|
| 外部入口 `target` | `private-zone` | string enum |
|
||||||
| 支付回跳 `returnTo` | `private-zoom` | string enum |
|
| 支付回跳 `returnTo` | `private-zone` | string enum |
|
||||||
| 锁定详情分类 | `private_zoom_moment` | string enum |
|
| 锁定详情分类 | `private_zone_moment` | string enum |
|
||||||
|
|
||||||
## 6. 数据库迁移
|
## 6. 数据库迁移
|
||||||
|
|
||||||
因为 `pre` 与生产当前共用 `https://dbapi.banlv-ai.com`,预发部署前先执行 unified 仓库中的临时桥接脚本:
|
因为 `pre` 与生产当前共用 `https://dbapi.banlv-ai.com`,预发部署前先执行 unified 仓库中的临时桥接脚本:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
database/private-zoom-bridge.sql
|
database/private-zone-bridge.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
桥接脚本会创建新表并在切换窗口内保持新旧解锁写入双向同步。生产前后端全部切换完成后,再执行最终迁移:
|
桥接脚本会创建新表并在切换窗口内保持新旧解锁写入双向同步。生产前后端全部切换完成后,再执行最终迁移:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
database/private-zoom-migration.sql
|
database/private-zone-migration.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
迁移会在同一事务中完成:
|
迁移会在同一事务中完成:
|
||||||
@@ -130,7 +130,7 @@ database/private-zoom-migration.sql
|
|||||||
- 将历史解锁表无损重命名;新旧表同时存在时先合并再删除旧表。
|
- 将历史解锁表无损重命名;新旧表同时存在时先合并再删除旧表。
|
||||||
- 重命名关联约束、索引、策略和触发器。
|
- 重命名关联约束、索引、策略和触发器。
|
||||||
- 合并 `users.preferences` 中历史解锁键,防止已解锁内容重新锁定。
|
- 合并 `users.preferences` 中历史解锁键,防止已解锁内容重新锁定。
|
||||||
- 把 `credit_ledger` 历史分类迁移到 `private_zoom` 和 `private_zoom_moment`。
|
- 把 `credit_ledger` 历史分类迁移到 `private_zone` 和 `private_zone_moment`。
|
||||||
- 定向迁移历史埋点键、页面 URL 和系统媒体路径,不改写用户聊天正文。
|
- 定向迁移历史埋点键、页面 URL 和系统媒体路径,不改写用户聊天正文。
|
||||||
- 删除切换窗口使用的双写触发器和函数。
|
- 删除切换窗口使用的双写触发器和函数。
|
||||||
- 支持重复执行。
|
- 支持重复执行。
|
||||||
@@ -140,13 +140,13 @@ database/private-zoom-migration.sql
|
|||||||
这是破坏性命名修正,不保留旧 API、旧页面路由或旧公开枚举作为正式兼容层。必须按以下顺序发布:
|
这是破坏性命名修正,不保留旧 API、旧页面路由或旧公开枚举作为正式兼容层。必须按以下顺序发布:
|
||||||
|
|
||||||
1. 备份数据库并记录当前 unified、前端和 Manager 版本。
|
1. 备份数据库并记录当前 unified、前端和 Manager 版本。
|
||||||
2. 执行 `database/private-zoom-bridge.sql`,确认新旧表双向写入一致。
|
2. 执行 `database/private-zone-bridge.sql`,确认新旧表双向写入一致。
|
||||||
3. 部署 unified `pre`,验证所有 `/api/private-zoom/*` 接口。
|
3. 部署 unified `pre`,验证所有 `/api/private-zone/*` 接口。
|
||||||
4. 部署前端 `pre`,验证页面、外部入口、解锁和支付回跳。
|
4. 部署前端 `pre`,验证页面、外部入口、解锁和支付回跳。
|
||||||
5. Manager 更新后验证积分用途显示为新分类。
|
5. Manager 更新后验证积分用途显示为新分类。
|
||||||
6. `pre` 验证通过后,先启动新生产后端并临时配置旧 API 到新 API 的 Nginx 转发。
|
6. `pre` 验证通过后,先启动新生产后端并临时配置旧 API 到新 API 的 Nginx 转发。
|
||||||
7. 切换生产后端,再部署同批次生产前端;确认新前端只调用 `/api/private-zoom/*`。
|
7. 切换生产后端,再部署同批次生产前端;确认新前端只调用 `/api/private-zone/*`。
|
||||||
8. 删除临时 Nginx 转发,执行 `database/private-zoom-migration.sql` 清理旧数据库对象。
|
8. 删除临时 Nginx 转发,执行 `database/private-zone-migration.sql` 清理旧数据库对象。
|
||||||
|
|
||||||
## 8. 错误与界面状态
|
## 8. 错误与界面状态
|
||||||
|
|
||||||
@@ -154,34 +154,34 @@ database/private-zoom-migration.sql
|
|||||||
- `403`:游客访问仅注册用户可用的关系日记操作,展示现有注册引导。
|
- `403`:游客访问仅注册用户可用的关系日记操作,展示现有注册引导。
|
||||||
- `404`:角色、图片包、内容或日记不存在,展示现有空状态或失效提示。
|
- `404`:角色、图片包、内容或日记不存在,展示现有空状态或失效提示。
|
||||||
- `409`:`expectedCost` 与后端价格不同,刷新列表后重新确认。
|
- `409`:`expectedCost` 与后端价格不同,刷新列表后重新确认。
|
||||||
- `402` 或业务余额不足错误:进入现有充值流程,并使用 `returnTo=private-zoom` 返回原角色页面。
|
- `402` 或业务余额不足错误:进入现有充值流程,并使用 `returnTo=private-zone` 返回原角色页面。
|
||||||
- 网络失败:不修改本地解锁状态,保留重试入口。
|
- 网络失败:不修改本地解锁状态,保留重试入口。
|
||||||
|
|
||||||
## 9. 验收用例
|
## 9. 验收用例
|
||||||
|
|
||||||
1. `/characters/elio/private-zoom`、Maya 和 Nayeli 对应页面均可打开。
|
1. `/characters/elio/private-zone`、Maya 和 Nayeli 对应页面均可打开。
|
||||||
2. `GET /api/private-zoom/albums` 返回当前角色内容,角色之间不混用。
|
2. `GET /api/private-zone/albums` 返回当前角色内容,角色之间不混用。
|
||||||
3. 已解锁历史记录在迁移后保持解锁状态,不重复扣积分。
|
3. 已解锁历史记录在迁移后保持解锁状态,不重复扣积分。
|
||||||
4. 新解锁成功后刷新页面仍为已解锁状态。
|
4. 新解锁成功后刷新页面仍为已解锁状态。
|
||||||
5. 积分不足进入充值页后,回跳到原角色 `/private-zoom` 页面。
|
5. 积分不足进入充值页后,回跳到原角色 `/private-zone` 页面。
|
||||||
6. `target=private-zoom&character=nayeli` 进入 Nayeli 对应页面。
|
6. `target=private-zone&character=nayeli` 进入 Nayeli 对应页面。
|
||||||
7. Manager 的积分用途不再出现旧分类。
|
7. Manager 的积分用途不再出现旧分类。
|
||||||
8. 三个仓库执行旧命名残留扫描,结果为 `0`。
|
8. 三个仓库执行旧命名残留扫描,结果为 `0`。
|
||||||
|
|
||||||
## 10. 当前测试证据
|
## 10. 当前测试证据
|
||||||
|
|
||||||
- unified:`316 passed`。
|
- unified:`316 passed`。
|
||||||
- 前端:TypeScript 类型检查通过;Vitest `639 passed`;ESLint 通过;契约测试 `4 passed`;生产构建通过并生成三个角色的 `/private-zoom` 页面。
|
- 前端:TypeScript 类型检查通过;Vitest `639 passed`;ESLint 通过;契约测试 `4 passed`;生产构建通过并生成三个角色的 `/private-zone` 页面。
|
||||||
- Manager:`75 passed`。
|
- Manager:`75 passed`。
|
||||||
- PostgreSQL 16 临时库:升级、重复升级、回滚和再次升级全部通过;历史解锁、偏好键、积分账本、埋点、媒体路径和数据库对象名均通过断言,临时容器已删除。
|
- PostgreSQL 16 临时库:升级、重复升级、回滚和再次升级全部通过;历史解锁、偏好键、积分账本、埋点、媒体路径和数据库对象名均通过断言,临时容器已删除。
|
||||||
- `pre`:unified `71da49b` 和前端 `35939e7` 已验证;新 API 已注册 `8` 条 `/api/private-zoom/*` 路径,旧 API 路径数量为 `0`;新页面返回 `200`,旧页面返回 `404`。
|
- `pre`:unified `71da49b` 和前端 `35939e7` 已验证;新 API 已注册 `8` 条 `/api/private-zone/*` 路径,旧 API 路径数量为 `0`;新页面返回 `200`,旧页面返回 `404`。
|
||||||
- 生产:unified 镜像 `ai-boyfriend-unified:prod-20260722-71da49b`(镜像 ID `sha256:755ef5741856d83d7d808e7df8ee259b6fa5fb94fe333b523ee280ed1d5f674b`)和前端镜像 `prod-35939e7` 已健康运行;Manager 当前版本为 `6eea005`。
|
- 生产:unified 镜像 `ai-boyfriend-unified:prod-20260722-71da49b`(镜像 ID `sha256:755ef5741856d83d7d808e7df8ee259b6fa5fb94fe333b523ee280ed1d5f674b`)和前端镜像 `prod-35939e7` 已健康运行;Manager 当前版本为 `6eea005`。
|
||||||
- 生产接口:测试账号调用 `GET https://api.banlv-ai.com/api/private-zoom/config?characterId=elio` 返回 `200`,`success=true`;旧版 API 路径在所有公开 API 入口均返回 `404`。
|
- 生产接口:测试账号调用 `GET https://api.banlv-ai.com/api/private-zone/config?characterId=elio` 返回 `200`,`success=true`;旧版 API 路径在所有公开 API 入口均返回 `404`。
|
||||||
- 生产数据库:最终迁移已执行,旧表、桥接函数、桥接触发器、旧偏好键、旧积分分类和旧埋点值的残留数量均为 `0`。
|
- 生产数据库:最终迁移已执行,旧表、桥接函数、桥接触发器、旧偏好键、旧积分分类和旧埋点值的残留数量均为 `0`。
|
||||||
|
|
||||||
## 11. 回滚影响
|
## 11. 回滚影响
|
||||||
|
|
||||||
最终迁移执行前可直接恢复旧应用,桥接表会保留双向一致的数据。最终迁移执行后,回滚必须同时执行 unified 仓库中的 `database/private-zoom-rollback.sql`。回滚前应停止写入,先保存切换后的新增解锁记录,再执行逆向迁移并恢复旧应用版本。不得只恢复前端或只恢复 unified。
|
最终迁移执行前可直接恢复旧应用,桥接表会保留双向一致的数据。最终迁移执行后,回滚必须同时执行 unified 仓库中的 `database/private-zone-rollback.sql`。回滚前应停止写入,先保存切换后的新增解锁记录,再执行逆向迁移并恢复旧应用版本。不得只恢复前端或只恢复 unified。
|
||||||
|
|
||||||
## 12. 待确认事项
|
## 12. 待确认事项
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
当前推荐部署链路:
|
当前推荐部署链路:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
push test/main
|
push pre/main
|
||||||
→ Gitea Actions 构建并推送 Docker 镜像
|
→ Gitea Actions 构建并推送 Docker 镜像
|
||||||
→ Actions 通过 SSH 登录目标服务器
|
→ Actions 通过 SSH 登录目标服务器
|
||||||
→ 目标服务器 docker pull 指定镜像 tag
|
→ 目标服务器 docker pull 指定镜像 tag
|
||||||
@@ -16,7 +16,7 @@ push test/main
|
|||||||
|
|
||||||
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
|
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `test` | 测试环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
| `pre` | 预发环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
||||||
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
|
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
|
||||||
|
|
||||||
## Gitea Secrets
|
## Gitea Secrets
|
||||||
@@ -75,14 +75,14 @@ prod-<short_sha> 最近 3 个
|
|||||||
|
|
||||||
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
|
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
|
||||||
|
|
||||||
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:测试环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:预发环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
||||||
|
|
||||||
## 本地发布入口
|
## 本地发布入口
|
||||||
|
|
||||||
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions:
|
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 测试环境
|
# 预发环境
|
||||||
./scripts/release/pre_release_web.sh
|
./scripts/release/pre_release_web.sh
|
||||||
|
|
||||||
# 生产环境
|
# 生产环境
|
||||||
@@ -92,16 +92,16 @@ prod-<short_sha> 最近 3 个
|
|||||||
也可以直接推送:
|
也可以直接推送:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push gitea test
|
git push gitea pre
|
||||||
git push --force gitea main
|
git push --force gitea main
|
||||||
```
|
```
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
测试环境:
|
预发环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh <user>@<test-host>
|
ssh <user>@<pre-host>
|
||||||
cd /opt/cozsweet-web-test
|
cd /opt/cozsweet-web-test
|
||||||
docker compose ps
|
docker compose ps
|
||||||
curl -I http://127.0.0.1:9135/
|
curl -I http://127.0.0.1:9135/
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ pnpm exec playwright install chromium
|
|||||||
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
|
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
|
||||||
|
|
||||||
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
|
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
|
||||||
refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier
|
refresh and paid-image unlock paths with the Pixel 7 profile. CI 在所有 pull
|
||||||
on every pull request and `dev` / `test` / `main` push.
|
request 以及 `dev` / `pre` / `main` push 时运行这一层测试。
|
||||||
|
|
||||||
## Environment overrides
|
## Environment overrides
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export interface MockCoreApisOptions {
|
|||||||
paidImageFlow?: boolean;
|
paidImageFlow?: boolean;
|
||||||
paidImageInsufficientCreditsFlow?: boolean;
|
paidImageInsufficientCreditsFlow?: boolean;
|
||||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||||
psidLoginFlow?: boolean;
|
topUpHandoffFlow?: boolean;
|
||||||
|
checkoutHandoffFlow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||||
@@ -22,14 +23,16 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
|||||||
paidImageFlow: options.paidImageFlow ?? false,
|
paidImageFlow: options.paidImageFlow ?? false,
|
||||||
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
||||||
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
||||||
psidLoginFlow: options.psidLoginFlow ?? false,
|
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
||||||
|
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
|
||||||
};
|
};
|
||||||
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
||||||
|
|
||||||
await registerAuthMocks(page, {
|
await registerAuthMocks(page, {
|
||||||
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
||||||
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
||||||
psidLoginFlow: chatOptions.psidLoginFlow,
|
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
||||||
|
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
|
||||||
});
|
});
|
||||||
await registerCharacterMocks(page);
|
await registerCharacterMocks(page);
|
||||||
await registerUserMocks(page);
|
await registerUserMocks(page);
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
import { apiEnvelope } from "../data/common";
|
import { apiEnvelope } from "../data/common";
|
||||||
import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth";
|
import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
||||||
|
|
||||||
export interface AuthMockState {
|
export interface AuthMockState {
|
||||||
chatSendTokenRefreshFlow: boolean;
|
chatSendTokenRefreshFlow: boolean;
|
||||||
isChatSendTokenExpired: () => boolean;
|
isChatSendTokenExpired: () => boolean;
|
||||||
psidLoginFlow: boolean;
|
topUpHandoffFlow: boolean;
|
||||||
|
checkoutHandoffFlow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
||||||
await page.route("**/api/auth/login/facebook/psid", async (route) => {
|
await page.route("**/api/auth/handoff/checkout/consume", async (route) => {
|
||||||
if (!state.psidLoginFlow) {
|
if (!state.checkoutHandoffFlow) {
|
||||||
await route.continue();
|
await route.continue();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await route.fulfill({ json: apiEnvelope(checkoutHandoffResponse) });
|
||||||
|
});
|
||||||
|
|
||||||
await route.fulfill({ json: apiEnvelope(psidLoginResponse) });
|
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
||||||
|
if (!state.topUpHandoffFlow) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: apiEnvelope(topUpHandoffResponse) });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/auth/guest", async (route) => {
|
await page.route("**/api/auth/guest", async (route) => {
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ export interface ChatMockState {
|
|||||||
export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) {
|
export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) {
|
||||||
let sendCount = 0;
|
let sendCount = 0;
|
||||||
|
|
||||||
|
await page.route("**/api/chat/opening-message", async (route) => {
|
||||||
|
const body = route.request().postDataJSON() as {
|
||||||
|
openingMessage?: unknown;
|
||||||
|
} | undefined;
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
messageId: "mock-opening-message",
|
||||||
|
created: true,
|
||||||
|
openingMessage:
|
||||||
|
typeof body?.openingMessage === "string"
|
||||||
|
? body.openingMessage
|
||||||
|
: "Hello",
|
||||||
|
createdAt: "2026-07-23T00:00:00Z",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/api/chat/history**", async (route) => {
|
await page.route("**/api/chat/history**", async (route) => {
|
||||||
const response = options.paidVoiceInsufficientCreditsFlow
|
const response = options.paidVoiceInsufficientCreditsFlow
|
||||||
? paidVoiceHistoryResponse
|
? paidVoiceHistoryResponse
|
||||||
@@ -44,7 +61,35 @@ export async function registerChatMocks(page: Page, options: ChatMockOptions, st
|
|||||||
await route.fulfill({ status: 401, json: { message: "expired" } });
|
await route.fulfill({ status: 401, json: { message: "expired" } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = options.paidImageFlow && message.includes("给我发图片")
|
const response = message.includes("discount offer test")
|
||||||
|
? {
|
||||||
|
...chatSendResponse,
|
||||||
|
reply: "I know you're thinking carefully about the price.",
|
||||||
|
messageId: "discount-action-message",
|
||||||
|
commercialAction: {
|
||||||
|
actionId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
type: "discountOffer",
|
||||||
|
copy: "Want me to ask for my best private offer?",
|
||||||
|
ctaLabel: "Yes, ask for me",
|
||||||
|
target: "discountConsent",
|
||||||
|
ruleId: "discount_after_price_objection",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: message.includes("private album offer test")
|
||||||
|
? {
|
||||||
|
...chatSendResponse,
|
||||||
|
reply: "You always know how to make me smile.",
|
||||||
|
messageId: "commercial-action-message",
|
||||||
|
commercialAction: {
|
||||||
|
actionId: "commercial-action-1",
|
||||||
|
type: "privateAlbumOffer",
|
||||||
|
copy: "There are more private photos waiting for you.",
|
||||||
|
ctaLabel: "Open private zone",
|
||||||
|
target: "privateZone",
|
||||||
|
ruleId: "private_album_after_appearance_praise",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: options.paidImageFlow && message.includes("给我发图片")
|
||||||
? paidImageChatSendResponse
|
? paidImageChatSendResponse
|
||||||
: options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt
|
: options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt
|
||||||
? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt)
|
? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt)
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { apiEnvelope } from "../data/common";
|
|||||||
import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment";
|
import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment";
|
||||||
|
|
||||||
export async function registerPaymentMocks(page: Page) {
|
export async function registerPaymentMocks(page: Page) {
|
||||||
|
await page.route("**/api/payment/commercial-offers/*/accept", async (route) => route.fulfill({ json: apiEnvelope({
|
||||||
|
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
planId: "vip_annual",
|
||||||
|
subscriptionType: "vip",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-24T08:00:00+00:00",
|
||||||
|
message: "I got it for you.",
|
||||||
|
promotionType: "commercial_seven_discount",
|
||||||
|
}) }));
|
||||||
await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) }));
|
await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) }));
|
||||||
await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }));
|
await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }));
|
||||||
await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }));
|
await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }));
|
||||||
|
|||||||
@@ -50,14 +50,28 @@ export const refreshedEmailLoginResponse = {
|
|||||||
refreshToken: "e2e-refreshed-email-refresh-token",
|
refreshToken: "e2e-refreshed-email-refresh-token",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const psidLoginResponse = {
|
export const topUpHandoffResponse = {
|
||||||
token: "e2e-psid-guest-token",
|
token: "e2e-messenger-token",
|
||||||
refreshToken: "",
|
refreshToken: "e2e-messenger-refresh-token",
|
||||||
matchedBy: "psid",
|
loginStatus: "facebookMessenger",
|
||||||
fbAsid: "",
|
merged: false,
|
||||||
fbPsid: "e2e-facebook-psid",
|
user: {
|
||||||
hasCompleteFacebookIdentity: false,
|
...e2eEmailUser,
|
||||||
isGuest: true,
|
id: "user_e2e_messenger",
|
||||||
user: e2eUser,
|
username: "E2E Messenger User",
|
||||||
userId: e2eUser.id,
|
email: "messenger_hash@messenger.cozsweet.invalid",
|
||||||
|
loginProvider: "facebook_messenger",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkoutHandoffResponse = {
|
||||||
|
token: "e2e-checkout-token",
|
||||||
|
refreshToken: "e2e-checkout-refresh-token",
|
||||||
|
loginStatus: "email",
|
||||||
|
user: e2eEmailUser,
|
||||||
|
checkoutIntent: {
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ export const paidImageChatSendResponse = {
|
|||||||
messageId: paidImageMessageId,
|
messageId: paidImageMessageId,
|
||||||
isGuest: true,
|
isGuest: true,
|
||||||
timestamp: 1_782_180_725_000,
|
timestamp: 1_782_180_725_000,
|
||||||
image: { type: "elio_schedule", url: paidImageUrl },
|
image: { type: "elio_schedule", url: null },
|
||||||
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null },
|
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createPaidImageHistoryResponse(unlocked: boolean) {
|
export function createPaidImageHistoryResponse(unlocked: boolean) {
|
||||||
return {
|
return {
|
||||||
messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: paidImageUrl }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }],
|
messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: unlocked ? paidImageUrl : null }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }],
|
||||||
total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
|
total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
export async function completeVipPayment(page: Page) {
|
export async function completeVipPayment(page: Page) {
|
||||||
|
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(checkoutButton).toBeEnabled();
|
||||||
|
|
||||||
|
await checkoutButton.click();
|
||||||
|
const renewalDialog = page.getByRole("dialog", {
|
||||||
|
name: "Automatic Renewal Confirmation",
|
||||||
|
});
|
||||||
|
await expect(renewalDialog).toBeVisible();
|
||||||
|
|
||||||
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
||||||
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
||||||
await page.getByRole("button", { name: /Pay and Activ/i }).click();
|
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
|
||||||
const createOrderRequest = await createOrderRequestPromise;
|
const createOrderRequest = await createOrderRequestPromise;
|
||||||
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
||||||
await orderStatusRequestPromise;
|
await orderStatusRequestPromise;
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ test("user can email login from other sign-in options and return to chat", async
|
|||||||
expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform);
|
expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform);
|
||||||
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Profile" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ test("user can log out from the profile after email login", async ({
|
|||||||
await expect(page).toHaveURL(/\/auth(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/auth(?:\?.*)?$/);
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
||||||
await expect(page.getByRole("button", { name: "Profile" })).toBeVisible();
|
await page.getByRole("link", { name: "Back to home" }).click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(defaultCharacterSplashPath.replace("?", "\\?") + "$"),
|
||||||
|
);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Profile" }).click();
|
await page.getByRole("button", { name: "Menu" }).click();
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(
|
||||||
new RegExp(defaultCharacterProfilePath.replace("?", "\\?") + "$"),
|
new RegExp(defaultCharacterProfilePath.replace("?", "\\?") + "$"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,49 +1,74 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import {
|
import { clearBrowserState } from "@e2e/fixtures/test-helpers";
|
||||||
clearBrowserState,
|
|
||||||
defaultCharacterChatUrl,
|
|
||||||
} from "@e2e/fixtures/test-helpers";
|
|
||||||
|
|
||||||
const psid = "e2e-facebook-psid";
|
const handoffToken = "e2e-handoff-token-that-is-at-least-32-characters";
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
await clearBrowserState(context, page, baseURL);
|
await clearBrowserState(context, page, baseURL);
|
||||||
await mockCoreApis(page, { psidLoginFlow: true });
|
await mockCoreApis(page, { topUpHandoffFlow: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user enters from an external PSID link and becomes a guest", async ({
|
test("user consumes a top-up handoff and opens credit packages as a formal Messenger user", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const psidLoginRequestPromise = page.waitForRequest(
|
const handoffRequestPromise = page.waitForRequest(
|
||||||
"**/api/auth/login/facebook/psid",
|
"**/api/auth/handoff/topup/consume",
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.goto(`/external-entry?target=chat&psid=${psid}`);
|
await page.goto(
|
||||||
|
`/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
const psidLoginRequest = await psidLoginRequestPromise;
|
const handoffRequest = await handoffRequestPromise;
|
||||||
expect(psidLoginRequest.method()).toBe("POST");
|
expect(handoffRequest.method()).toBe("POST");
|
||||||
expect(psidLoginRequest.postDataJSON()).toMatchObject({
|
expect(handoffRequest.postDataJSON()).toEqual({ handoffToken });
|
||||||
psid,
|
|
||||||
bindToGuest: true,
|
|
||||||
});
|
|
||||||
expect(psidLoginRequest.postDataJSON().deviceId).toEqual(expect.any(String));
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(defaultCharacterChatUrl);
|
await expect(page).toHaveURL("/subscription?type=topup");
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("button", { name: /Pay and Top Up/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page.evaluate(() => ({
|
page.evaluate(() => ({
|
||||||
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
||||||
guestToken: localStorage.getItem("cozsweet:guest_token"),
|
loginToken: localStorage.getItem("cozsweet:login_token"),
|
||||||
psid: localStorage.getItem("cozsweet:psid"),
|
refreshToken: localStorage.getItem("cozsweet:refresh_token"),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
loginProvider: "guest",
|
loginProvider: "facebookMessenger",
|
||||||
guestToken: "e2e-psid-guest-token",
|
loginToken: "e2e-messenger-token",
|
||||||
psid,
|
refreshToken: "e2e-messenger-refresh-token",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("invalid top-up handoff does not log in and is removed from the URL", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 410,
|
||||||
|
json: {
|
||||||
|
detail: {
|
||||||
|
code: "HANDOFF_EXPIRED",
|
||||||
|
message: "充值链接已过期",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL("/external-entry?target=topup");
|
||||||
|
await expect(
|
||||||
|
page.getByText(/invalid or has expired/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => localStorage.getItem("cozsweet:login_provider")))
|
||||||
|
.not.toBe("facebookMessenger");
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ test("guest user avatar returns to Profile after sign-in", async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const character of characters) {
|
for (const character of characters) {
|
||||||
test(`${character.displayName} avatar opens the matching Private Zoom`, async ({
|
test(`${character.displayName} avatar opens the matching Private Zone`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await enterCharacterChat(page, character.slug);
|
await enterCharacterChat(page, character.slug);
|
||||||
@@ -50,13 +50,13 @@ for (const character of characters) {
|
|||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole("button", {
|
.getByRole("button", {
|
||||||
name: `Open ${character.displayName}'s private zoom`,
|
name: `Open ${character.displayName}'s private zone`,
|
||||||
})
|
})
|
||||||
.last()
|
.last()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(
|
||||||
new RegExp(`/characters/${character.slug}/private-zoom(?:\\?.*)?$`),
|
new RegExp(`/characters/${character.slug}/private-zone(?:\\?.*)?$`),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
enterChatFromSplash,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a backend commercial action and opens the private zone", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await enterChatFromSplash(page);
|
||||||
|
|
||||||
|
const input = page.getByRole("textbox", { name: "Message" });
|
||||||
|
await expect(input).toBeEnabled();
|
||||||
|
await input.fill("private album offer test");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
const offer = page.getByLabel("Open private zone");
|
||||||
|
await expect(offer).toContainText(
|
||||||
|
"There are more private photos waiting for you.",
|
||||||
|
);
|
||||||
|
await offer.getByRole("button", { name: "Open private zone" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/characters\/elio\/private-zone$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asks for consent before opening a user-bound discount plan", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await enterChatFromSplash(page);
|
||||||
|
|
||||||
|
const input = page.getByRole("textbox", { name: "Message" });
|
||||||
|
await input.fill("discount offer test");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
const offer = page.getByLabel("Yes, ask for me");
|
||||||
|
await expect(offer).toContainText("Want me to ask for my best private offer?");
|
||||||
|
await offer.getByRole("button", { name: "Yes, ask for me" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/subscription\?type=vip.*planId=vip_annual.*commercialOfferId=13ec8a10/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -57,16 +57,16 @@ test("favorite entry and three-tab Menu navigation render on the real pages", as
|
|||||||
name: "Primary navigation",
|
name: "Primary navigation",
|
||||||
});
|
});
|
||||||
await expect(navigation).toContainText("Chat");
|
await expect(navigation).toContainText("Chat");
|
||||||
await expect(navigation).toContainText("Elio Private Zoom");
|
await expect(navigation).toContainText("Private Zone");
|
||||||
await expect(navigation).toContainText("Menu");
|
await expect(navigation).toContainText("Menu");
|
||||||
await savePreview(page, `${mobile ? "mobile" : "desktop"}-splash.png`);
|
await savePreview(page, `${mobile ? "mobile" : "desktop"}-splash.png`);
|
||||||
|
|
||||||
await page.goto("/characters/elio/private-zoom");
|
await page.goto("/characters/elio/private-zone");
|
||||||
await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||||
await savePreview(
|
await savePreview(
|
||||||
page,
|
page,
|
||||||
`${mobile ? "mobile" : "desktop"}-private-zoom.png`,
|
`${mobile ? "mobile" : "desktop"}-private-zone.png`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Menu" }).click();
|
await page.getByRole("button", { name: "Menu" }).click();
|
||||||
@@ -94,8 +94,16 @@ test("favorite entry and three-tab Menu navigation render on the real pages", as
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.goto(defaultCharacterChatPath);
|
await page.goto(defaultCharacterChatPath);
|
||||||
await expect(page.getByRole("button", { name: "Saved" })).toBeVisible();
|
const iosDownload = page.getByRole("button", { name: "Download" });
|
||||||
await savePreview(page, "mobile-ios-external-chat-saved.png");
|
await expect(iosDownload).toBeVisible();
|
||||||
|
await iosDownload.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Add CozSweet to Home Screen" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("dialog")).toContainText(
|
||||||
|
"In Safari, tap the Share button, then choose “Add to Home Screen”.",
|
||||||
|
);
|
||||||
|
await savePreview(page, "mobile-ios-external-chat-download.png");
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(browserErrors.filter(isFeatureRuntimeError)).toEqual([]);
|
expect(browserErrors.filter(isFeatureRuntimeError)).toEqual([]);
|
||||||
@@ -111,7 +119,7 @@ async function registerFavoriteMenuMocks(page: Page): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route("**/api/private-zoom/albums**", async (route) => {
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: apiEnvelope({ items: [], creditBalance: 80 }),
|
json: apiEnvelope({ items: [], creditBalance: 80 }),
|
||||||
});
|
});
|
||||||
@@ -162,7 +170,7 @@ async function registerFavoriteMenuMocks(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pathname === "/api/private-zoom/albums") {
|
if (pathname === "/api/private-zone/albums") {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: apiEnvelope({ items: [], creditBalance: 80 }),
|
json: apiEnvelope({ items: [], creditBalance: 80 }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { clearBrowserState } from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const characters = [
|
||||||
|
{
|
||||||
|
id: "elio",
|
||||||
|
slug: "elio",
|
||||||
|
displayName: "Elio Silvestri",
|
||||||
|
shortName: "Elio",
|
||||||
|
cover: "elio.png",
|
||||||
|
splashCover: "elio.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maya-tan",
|
||||||
|
slug: "maya",
|
||||||
|
displayName: "Maya Tan",
|
||||||
|
shortName: "Maya",
|
||||||
|
cover: "maya.webp",
|
||||||
|
splashCover: "maya-home.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nayeli-cervantes",
|
||||||
|
slug: "nayeli",
|
||||||
|
displayName: "Nayeli Cervantes",
|
||||||
|
shortName: "Nayeli",
|
||||||
|
cover: "nayeli.webp",
|
||||||
|
splashCover: "nayeli.webp",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const previewDirectory = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
".codex",
|
||||||
|
"artifacts",
|
||||||
|
"frontend-preview",
|
||||||
|
"2026-07-22",
|
||||||
|
"multi-role-commercial",
|
||||||
|
);
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
await registerMultiRoleCommercialMocks(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const character of characters) {
|
||||||
|
test(`${character.displayName} homepage renders its assigned cover`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const mobile = testInfo.project.name.includes("mobile");
|
||||||
|
await page.setViewportSize(
|
||||||
|
mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/characters/${character.slug}/splash`);
|
||||||
|
|
||||||
|
const cover = page.locator(`img[src*="${character.splashCover}"]`);
|
||||||
|
await expect(cover).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("navigation", { name: "Primary navigation" }),
|
||||||
|
).toContainText("Private Zone");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
cover.evaluate((image: HTMLImageElement) =>
|
||||||
|
image.complete ? image.naturalWidth : 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.FRONTEND_PREVIEW_SCREENSHOTS === "1" &&
|
||||||
|
character.id !== "elio"
|
||||||
|
) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(
|
||||||
|
previewDirectory,
|
||||||
|
`${character.slug}-${mobile ? "mobile-390x844" : "desktop-1440x900"}.png`,
|
||||||
|
),
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${character.displayName} can open a character-scoped Private Zone`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const momentsRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/private-zone/posts" &&
|
||||||
|
url.searchParams.get("characterId") === character.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const albumRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/private-zone/albums" &&
|
||||||
|
url.searchParams.get("characterId") === character.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/characters/${character.slug}/private-zone`);
|
||||||
|
|
||||||
|
await Promise.all([momentsRequest, albumRequest]);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab")).toHaveText(["Albums", "Moments"]);
|
||||||
|
await expect(page.getByRole("tab", { name: "Albums" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(page.getByText(character.displayName).last()).toBeVisible();
|
||||||
|
const albumButton = page.getByRole("button", {
|
||||||
|
name: `View locked collection with 8 images from ${character.displayName}`,
|
||||||
|
});
|
||||||
|
if (character.id === "maya-tan") {
|
||||||
|
await expect(albumButton).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
|
||||||
|
await expect(page.getByText("No private albums yet.")).toHaveCount(0);
|
||||||
|
} else {
|
||||||
|
await expect(albumButton).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${character.displayName} receives a character-scoped Tip catalog`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const catalogRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/payment/gift-products" &&
|
||||||
|
url.searchParams.get("characterId") === character.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/characters/${character.slug}/tip`);
|
||||||
|
|
||||||
|
await catalogRequest;
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: `Buy ${character.shortName} a coffee` }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("radio", { name: /Small Coffee/ })).toBeChecked();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Order and Buy" }),
|
||||||
|
).toBeEnabled();
|
||||||
|
await expect(
|
||||||
|
page.getByText("This character does not have any gifts available yet."),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a real Private Zone request failure still offers Retry", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
|
await route.fulfill({ status: 503, json: { message: "Albums unavailable" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/characters/maya/private-zone");
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerMultiRoleCommercialMocks(page: Page): Promise<void> {
|
||||||
|
await page.route("**/api/private-zone/posts**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
characterId,
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
creditBalance: 1000,
|
||||||
|
currency: "credits",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||||
|
const character =
|
||||||
|
characters.find((candidate) => candidate.id === characterId) ?? characters[0];
|
||||||
|
|
||||||
|
const hasCompleteAlbum = character.id !== "maya-tan";
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
creditBalance: 1000,
|
||||||
|
pendingImageCount: hasCompleteAlbum ? 0 : 7,
|
||||||
|
hasIncompleteContent: !hasCompleteAlbum,
|
||||||
|
items: hasCompleteAlbum ? [
|
||||||
|
{
|
||||||
|
albumId: `album-${character.id}`,
|
||||||
|
title: `${character.shortName} private photos`,
|
||||||
|
content: null,
|
||||||
|
previewText: "Unlock this private album",
|
||||||
|
imageCount: 8,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: `/images/private-zone/banner/${character.slug}.png`,
|
||||||
|
locked: true,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locked: true,
|
||||||
|
unlocked: false,
|
||||||
|
unlockCost: 320,
|
||||||
|
publishedAt: "2026-07-22T10:00:00+08:00",
|
||||||
|
lockDetail: { locked: true },
|
||||||
|
},
|
||||||
|
] : [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/payment/gift-products**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||||
|
const planPrefix =
|
||||||
|
characterId === "elio"
|
||||||
|
? "tip"
|
||||||
|
: `tip_${characterId.replaceAll("-", "_")}`;
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
characterId,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
category: "coffee",
|
||||||
|
name: "Coffee",
|
||||||
|
productCount: 1,
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
plans: [
|
||||||
|
{
|
||||||
|
planId: `${planPrefix}_coffee_usd_4_99`,
|
||||||
|
planName: "Small Coffee",
|
||||||
|
orderType: "tip",
|
||||||
|
tipType: "coffee_small",
|
||||||
|
category: "coffee",
|
||||||
|
characterId,
|
||||||
|
description: "A character-scoped coffee gift",
|
||||||
|
imageUrl: null,
|
||||||
|
amountCents: 499,
|
||||||
|
currency: "USD",
|
||||||
|
autoRenew: false,
|
||||||
|
isFirstRechargeOffer: false,
|
||||||
|
firstRechargeDiscountPercent: 0,
|
||||||
|
promotionType: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { createPaidImageHistoryResponse } from "@e2e/fixtures/data/chat";
|
||||||
import {
|
import {
|
||||||
paidImageDisplayMessageId,
|
paidImageDisplayMessageId,
|
||||||
paidImageMessageId,
|
paidImageMessageId,
|
||||||
@@ -17,10 +19,6 @@ import {
|
|||||||
switchToEmailSignIn,
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
|
||||||
`${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
|
|
||||||
);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
await clearBrowserState(context, page, baseURL);
|
await clearBrowserState(context, page, baseURL);
|
||||||
await mockCoreApis(page, {
|
await mockCoreApis(page, {
|
||||||
@@ -46,26 +44,18 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
message: "给我发图片",
|
message: "给我发图片",
|
||||||
});
|
});
|
||||||
|
|
||||||
const paidImageButton = page.getByRole("button", {
|
const lockedImageCard = page.getByRole("group", {
|
||||||
name: "Open image in fullscreen",
|
name: "Locked private image",
|
||||||
}).last();
|
}).last();
|
||||||
await expect(paidImageButton).toBeVisible();
|
await expect(lockedImageCard).toBeVisible();
|
||||||
|
await expect(lockedImageCard.locator("img")).toHaveCount(0);
|
||||||
await paidImageButton.click();
|
await lockedImageCard
|
||||||
await expect(page).toHaveURL(paidImageOverlayUrl);
|
.getByRole("button", { name: "Unlock private image" })
|
||||||
|
|
||||||
const lockedImageDialog = page.getByRole("dialog", {
|
|
||||||
name: "Locked fullscreen image",
|
|
||||||
});
|
|
||||||
await expect(lockedImageDialog).toBeVisible();
|
|
||||||
|
|
||||||
await lockedImageDialog
|
|
||||||
.getByRole("button", { name: "Unlock high-definition large image" })
|
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
||||||
`${defaultCharacterChatPath}?image=${encodeURIComponent(paidImageDisplayMessageId)}`,
|
defaultCharacterChatPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
@@ -73,7 +63,7 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await submitEmailLogin(page, {
|
await submitEmailLogin(page, {
|
||||||
expectedUrl: paidImageOverlayUrl,
|
expectedUrl: new RegExp(`${defaultCharacterChatPath}$`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
@@ -97,3 +87,38 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fullscreen image closes from its back button, the image, and browser history Back", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/chat/history**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope(createPaidImageHistoryResponse(true)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await enterChatFromSplash(page);
|
||||||
|
await dismissChatInterruptions(page);
|
||||||
|
|
||||||
|
const imageButton = page.getByRole("button", {
|
||||||
|
name: "Open image in fullscreen",
|
||||||
|
});
|
||||||
|
const imageDialog = page.getByRole("dialog", { name: "Fullscreen image" });
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await imageDialog.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await imageDialog.locator("img").click();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await page.goBack();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { stripePaymentDialogStyles } from "@/app/_components/payment/stripe-payment-dialog.styles";
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ width: 390, height: 844 },
|
||||||
|
{ width: 405, height: 797 },
|
||||||
|
{ width: 540, height: 900 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
test(`payment portal stays viewport-bound at ${viewport.width}x${viewport.height}`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto("/subscription?type=topup", {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
|
||||||
|
await mountPaymentPanel(page, 180);
|
||||||
|
await expectViewportScrim(page, viewport);
|
||||||
|
await expectCenteredShortPanel(page, viewport);
|
||||||
|
|
||||||
|
await mountPaymentPanel(page, 1_200);
|
||||||
|
await expectViewportScrim(page, viewport);
|
||||||
|
await expectScrollableTallPanel(page, viewport);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPaymentPanel(page: Page, contentHeight: number) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ dialogClassName, overlayClassName, requestedContentHeight }) => {
|
||||||
|
document.querySelector('[data-testid="payment-layout-overlay"]')?.remove();
|
||||||
|
document.querySelector('[data-testid="transformed-checkout-host"]')?.remove();
|
||||||
|
|
||||||
|
const transformedCheckoutHost = document.createElement("div");
|
||||||
|
transformedCheckoutHost.dataset.testid = "transformed-checkout-host";
|
||||||
|
transformedCheckoutHost.style.transform = "translateX(50%)";
|
||||||
|
document.body.appendChild(transformedCheckoutHost);
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.dataset.testid = "payment-layout-overlay";
|
||||||
|
overlay.className = overlayClassName;
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.dataset.testid = "payment-layout-panel";
|
||||||
|
panel.className = dialogClassName;
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.style.height = `${requestedContentHeight}px`;
|
||||||
|
content.textContent = "Complete payment";
|
||||||
|
panel.appendChild(content);
|
||||||
|
overlay.appendChild(panel);
|
||||||
|
|
||||||
|
// Mirrors ModalPortal: the overlay must be a body child, never a child of
|
||||||
|
// the transformed checkout host that triggered it.
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dialogClassName: stripePaymentDialogStyles.dialog,
|
||||||
|
overlayClassName: stripePaymentDialogStyles.overlay,
|
||||||
|
requestedContentHeight: contentHeight,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectViewportScrim(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const overlay = page.getByTestId("payment-layout-overlay");
|
||||||
|
await expect(overlay).toBeVisible();
|
||||||
|
const box = await overlay.boundingBox();
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.x).toBeCloseTo(0, 0);
|
||||||
|
expect(box?.y).toBeCloseTo(0, 0);
|
||||||
|
expect(box?.width).toBeCloseTo(viewport.width, 0);
|
||||||
|
expect(box?.height).toBeCloseTo(viewport.height, 0);
|
||||||
|
await expect(overlay.evaluate((node) => node.parentElement === document.body))
|
||||||
|
.resolves.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCenteredShortPanel(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const panel = page.getByTestId("payment-layout-panel");
|
||||||
|
const box = await panel.boundingBox();
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.y ?? 0).toBeGreaterThan(100);
|
||||||
|
expect(Math.abs((box?.y ?? 0) * 2 + (box?.height ?? 0) - viewport.height))
|
||||||
|
.toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectScrollableTallPanel(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const panel = page.getByTestId("payment-layout-panel");
|
||||||
|
const box = await panel.boundingBox();
|
||||||
|
const scrollMetrics = await panel.evaluate((node) => ({
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.y ?? 0).toBeGreaterThanOrEqual(15);
|
||||||
|
expect(box?.y ?? 100).toBeLessThanOrEqual(25);
|
||||||
|
expect((box?.y ?? 0) + (box?.height ?? 0))
|
||||||
|
.toBeLessThanOrEqual(viewport.height - 15);
|
||||||
|
expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const facebookAndroidUserAgent =
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 " +
|
||||||
|
"[FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
|
||||||
|
const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters";
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
userAgent: facebookAndroidUserAgent,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consumes the handoff, removes its token, and does not create an order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page, { checkoutHandoffFlow: true });
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const consumeRequest = page.waitForRequest(
|
||||||
|
"**/api/auth/handoff/checkout/consume",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/external-entry?target=checkout&character=nayeli&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/subscription\?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=.*&payChannel=stripe/,
|
||||||
|
);
|
||||||
|
expect(page.url()).not.toContain("handoffToken");
|
||||||
|
await expect.poll(() => createOrderRequests).toBe(0);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
||||||
|
loginToken: localStorage.getItem("cozsweet:login_token"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.toEqual({ loginProvider: "email", loginToken: "e2e-checkout-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers the Facebook external-browser path before creating a Stripe order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.route("**/api/auth/handoff/checkout", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque-token",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.goto("/subscription?type=vip&payChannel=stripe&character=maya");
|
||||||
|
await page.getByRole("button", { name: /Monthly,/i }).click();
|
||||||
|
const externalButton = page.getByRole("button", {
|
||||||
|
name: "Open in browser for more payment methods",
|
||||||
|
});
|
||||||
|
const paymentButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(externalButton).toBeVisible();
|
||||||
|
await expect(externalButton).toBeEnabled();
|
||||||
|
await expect(paymentButton).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
const externalBox = await externalButton.boundingBox();
|
||||||
|
const paymentBox = await paymentButton.boundingBox();
|
||||||
|
expect(externalBox).not.toBeNull();
|
||||||
|
expect(paymentBox).not.toBeNull();
|
||||||
|
expect(externalBox!.y).toBeLessThan(paymentBox!.y);
|
||||||
|
expect(paymentBox!.y + paymentBox!.height).toBeLessThanOrEqual(844);
|
||||||
|
const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout");
|
||||||
|
await externalButton.click();
|
||||||
|
expect((await handoffRequest).postDataJSON()).toMatchObject({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
});
|
||||||
|
expect(createOrderRequests).toBe(0);
|
||||||
|
});
|
||||||
@@ -70,6 +70,7 @@ const idrGiftCatalog = {
|
|||||||
async function registerIndonesiaPaymentMocks(page: Page) {
|
async function registerIndonesiaPaymentMocks(page: Page) {
|
||||||
const orderStatuses = new Map<string, "pending" | "paid">();
|
const orderStatuses = new Map<string, "pending" | "paid">();
|
||||||
const orderPlans = new Map<string, { planId: string; orderType: string }>();
|
const orderPlans = new Map<string, { planId: string; orderType: string }>();
|
||||||
|
let createOrderCount = 0;
|
||||||
|
|
||||||
await page.route("**/api/user/profile", async (route) => {
|
await page.route("**/api/user/profile", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -83,11 +84,15 @@ async function registerIndonesiaPaymentMocks(page: Page) {
|
|||||||
await route.fulfill({ json: apiEnvelope(idrGiftCatalog) });
|
await route.fulfill({ json: apiEnvelope(idrGiftCatalog) });
|
||||||
});
|
});
|
||||||
await page.route("**/api/payment/create-order", async (route) => {
|
await page.route("**/api/payment/create-order", async (route) => {
|
||||||
const body = route.request().postDataJSON() as { planId: string };
|
createOrderCount += 1;
|
||||||
|
const body = route.request().postDataJSON() as {
|
||||||
|
planId: string;
|
||||||
|
payChannel: "ezpay" | "stripe";
|
||||||
|
};
|
||||||
const plan =
|
const plan =
|
||||||
idrPlans.plans.find((item) => item.planId === body.planId) ??
|
idrPlans.plans.find((item) => item.planId === body.planId) ??
|
||||||
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
|
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
|
||||||
const orderId = `order_qris_${body.planId}`;
|
const orderId = `order_${body.payChannel}_${body.planId}`;
|
||||||
orderStatuses.set(orderId, "pending");
|
orderStatuses.set(orderId, "pending");
|
||||||
orderPlans.set(orderId, {
|
orderPlans.set(orderId, {
|
||||||
planId: body.planId,
|
planId: body.planId,
|
||||||
@@ -96,7 +101,13 @@ async function registerIndonesiaPaymentMocks(page: Page) {
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: apiEnvelope({
|
json: apiEnvelope({
|
||||||
orderId,
|
orderId,
|
||||||
payParams: {
|
payParams:
|
||||||
|
body.payChannel === "stripe"
|
||||||
|
? {
|
||||||
|
provider: "stripe",
|
||||||
|
clientSecret: "pi_e2e_secret_mock",
|
||||||
|
}
|
||||||
|
: {
|
||||||
provider: "ezpay",
|
provider: "ezpay",
|
||||||
countryCode: "ID",
|
countryCode: "ID",
|
||||||
channelCode: "ID_QRIS_DYNAMIC_QR",
|
channelCode: "ID_QRIS_DYNAMIC_QR",
|
||||||
@@ -138,6 +149,9 @@ async function registerIndonesiaPaymentMocks(page: Page) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
getCreateOrderCount() {
|
||||||
|
return createOrderCount;
|
||||||
|
},
|
||||||
markPaid(orderId: string) {
|
markPaid(orderId: string) {
|
||||||
orderStatuses.set(orderId, "paid");
|
orderStatuses.set(orderId, "paid");
|
||||||
},
|
},
|
||||||
@@ -168,6 +182,7 @@ async function expectQrisOrder(
|
|||||||
expect(request.postDataJSON()).toMatchObject({
|
expect(request.postDataJSON()).toMatchObject({
|
||||||
planId,
|
planId,
|
||||||
payChannel: "ezpay",
|
payChannel: "ezpay",
|
||||||
|
recipientCharacterId: "elio",
|
||||||
});
|
});
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
@@ -177,7 +192,21 @@ async function expectQrisOrder(
|
|||||||
await expect(
|
await expect(
|
||||||
dialog.getByRole("img", { name: "QRIS payment QR code" }),
|
dialog.getByRole("img", { name: "QRIS payment QR code" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
return `order_qris_${planId}`;
|
return `order_ezpay_${planId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCheckoutButtonLayout(page: Page) {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
const checkoutBox = await page
|
||||||
|
.getByRole("button", { name: "Pay and Top Up" })
|
||||||
|
.boundingBox();
|
||||||
|
const stripeBox = await page.getByRole("button", { name: "Stripe" }).boundingBox();
|
||||||
|
expect(viewport).not.toBeNull();
|
||||||
|
expect(checkoutBox).not.toBeNull();
|
||||||
|
expect(stripeBox).not.toBeNull();
|
||||||
|
if (!viewport || !checkoutBox || !stripeBox) return;
|
||||||
|
expect(checkoutBox.y + checkoutBox.height).toBeLessThanOrEqual(viewport.height);
|
||||||
|
expect(stripeBox.y + stripeBox.height).toBeLessThanOrEqual(checkoutBox.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
@@ -190,18 +219,33 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
|
|||||||
}) => {
|
}) => {
|
||||||
const payment = await registerIndonesiaPaymentMocks(page);
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
await prepareIndonesiaUser(page);
|
await prepareIndonesiaUser(page);
|
||||||
await page.goto("/subscription?type=vip");
|
await page.goto("/subscription?type=vip&character=elio");
|
||||||
|
|
||||||
|
await expect(page.getByText("Choose who you want to support")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Elio", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
|
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(checkoutButton).toBeEnabled();
|
||||||
|
await expectCheckoutButtonLayout(page);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(0);
|
||||||
|
|
||||||
const orderId = await expectQrisOrder(
|
const orderId = await expectQrisOrder(
|
||||||
page,
|
page,
|
||||||
/Pay and Activ/i,
|
/Pay and Top Up/i,
|
||||||
"vip_monthly",
|
"vip_monthly",
|
||||||
/Rp\s*195[.,]900/,
|
/Rp\s*195[.,]900/,
|
||||||
);
|
);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
payment.markPaid(orderId);
|
payment.markPaid(orderId);
|
||||||
|
|
||||||
const success = page.getByRole("alertdialog", { name: "Payment successful" });
|
const success = page.getByRole("alertdialog", { name: "Payment successful" });
|
||||||
@@ -214,7 +258,9 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
|
|||||||
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||||
const payment = await registerIndonesiaPaymentMocks(page);
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
await prepareIndonesiaUser(page);
|
await prepareIndonesiaUser(page);
|
||||||
await page.goto("/subscription?type=topup");
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||||
|
await expectCheckoutButtonLayout(page);
|
||||||
|
|
||||||
const orderId = await expectQrisOrder(
|
const orderId = await expectQrisOrder(
|
||||||
page,
|
page,
|
||||||
@@ -229,6 +275,123 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
|||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
const orderId = await expectQrisOrder(
|
||||||
|
page,
|
||||||
|
/Pay and Top Up/i,
|
||||||
|
"dol_1000",
|
||||||
|
/Rp\s*88[.,]900/,
|
||||||
|
);
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
const resumeButton = page.getByRole("button", {
|
||||||
|
name: "Resume QRIS payment",
|
||||||
|
});
|
||||||
|
await expect(resumeButton).toBeEnabled();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
|
await resumeButton.click();
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
|
payment.markPaid(orderId);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("alertdialog", { name: "Payment successful" }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
await expectQrisOrder(
|
||||||
|
page,
|
||||||
|
/Pay and Top Up/i,
|
||||||
|
"dol_1000",
|
||||||
|
/Rp\s*88[.,]900/,
|
||||||
|
);
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
|
const stripeButton = page.getByRole("button", {
|
||||||
|
name: "Stripe",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(stripeButton).toBeEnabled();
|
||||||
|
await stripeButton.click();
|
||||||
|
await expect(stripeButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Resume QRIS payment" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
const stripeRequestPromise = page.waitForRequest(
|
||||||
|
"**/api/payment/create-order",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||||
|
const stripeRequest = await stripeRequestPromise;
|
||||||
|
expect(stripeRequest.postDataJSON()).toMatchObject({
|
||||||
|
planId: "dol_1000",
|
||||||
|
payChannel: "stripe",
|
||||||
|
recipientCharacterId: "elio",
|
||||||
|
});
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("payment issue submits Other to the feedback API without creating an order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
let feedbackBody = "";
|
||||||
|
let idempotencyKey = "";
|
||||||
|
await page.route("**/api/feedback", async (route) => {
|
||||||
|
feedbackBody = route.request().postData() ?? "";
|
||||||
|
idempotencyKey = route.request().headers()["idempotency-key"] ?? "";
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({ feedbackId: "feedback-payment-1", duplicate: false }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
await page.getByRole("button", { name: "Payment issue?" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", {
|
||||||
|
name: "What problem did you encounter?",
|
||||||
|
});
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole("radio", { name: "Other" }).check();
|
||||||
|
await dialog
|
||||||
|
.getByLabel("Please describe the issue")
|
||||||
|
.fill("QRIS did not open correctly.");
|
||||||
|
await dialog.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("status")).toHaveText(
|
||||||
|
"Thanks. Your payment issue has been submitted.",
|
||||||
|
);
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(0);
|
||||||
|
expect(idempotencyKey).toMatch(/^payment_feedback_[A-Za-z0-9_-]+$/);
|
||||||
|
expect(feedbackBody).toContain('name="category"');
|
||||||
|
expect(feedbackBody).toContain("payment");
|
||||||
|
expect(feedbackBody).toContain(
|
||||||
|
"Payment issue: Other\r\nDetails: QRIS did not open correctly.",
|
||||||
|
);
|
||||||
|
expect(feedbackBody).toContain('name="context"');
|
||||||
|
expect(feedbackBody).toContain('"paymentIssueReason":"other"');
|
||||||
|
expect(feedbackBody).toContain('"characterId":"elio"');
|
||||||
|
});
|
||||||
|
|
||||||
test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({
|
test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -243,6 +406,18 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
|
|||||||
"tip_coffee_usd_4_99",
|
"tip_coffee_usd_4_99",
|
||||||
/Rp\s*89[.,]299/,
|
/Rp\s*89[.,]299/,
|
||||||
);
|
);
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
|
const resumeButton = page.getByRole("button", {
|
||||||
|
name: "Resume QRIS payment",
|
||||||
|
});
|
||||||
|
await expect(resumeButton).toBeEnabled();
|
||||||
|
await resumeButton.click();
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
payment.markPaid(orderId);
|
payment.markPaid(orderId);
|
||||||
|
|
||||||
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("serves the VIP membership benefits agreement from the same site", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto("/legal/vip-membership-benefits.html");
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "VIP Membership Benefits Agreement" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves the automatic renewal agreement from the same site", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto("/legal/automatic-renewal.html");
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Automatic Renewal Agreement" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const postId = "11111111-1111-1111-1111-111111111111";
|
||||||
|
let unlocked = false;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
unlocked = false;
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
await registerPrivateZoneVideoMocks(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Private Zone defaults to Albums and reveals video after switching to Moments", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedEmailSession(page);
|
||||||
|
|
||||||
|
const listRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/private-zone/posts" &&
|
||||||
|
url.searchParams.get("characterId") === "maya-tan"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.goto("/characters/maya/private-zone");
|
||||||
|
await listRequest;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Maya archive")).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: "Moments" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private moments" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Only for you.")).toBeVisible();
|
||||||
|
await expect(page.locator("video")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Unlock for 100 credits" }).click();
|
||||||
|
const dialog = page.getByRole("alertdialog");
|
||||||
|
await expect(dialog).toContainText("100 credits");
|
||||||
|
const unlockRequest = page.waitForRequest(
|
||||||
|
`**/api/private-zone/posts/${postId}/unlock`,
|
||||||
|
);
|
||||||
|
await dialog.getByRole("button", { name: "Unlock", exact: true }).click();
|
||||||
|
expect((await unlockRequest).postDataJSON()).toEqual({ expectedCost: 100 });
|
||||||
|
|
||||||
|
const video = page.locator("video");
|
||||||
|
await expect(video).toHaveCount(1);
|
||||||
|
await expect(video.locator("source")).toHaveAttribute("src", /video\.mp4/);
|
||||||
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Albums render first and Moments remain available behind the second tab", async ({ page }) => {
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.goto("/characters/maya/private-zone");
|
||||||
|
|
||||||
|
const tabs = page.getByRole("tab");
|
||||||
|
await expect(tabs).toHaveText(["Albums", "Moments"]);
|
||||||
|
await expect(page.getByRole("tab", { name: "Albums" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Maya archive")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Moments" }).click();
|
||||||
|
await expect(page.getByRole("tab", { name: "Moments" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private moments" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerPrivateZoneVideoMocks(page: Page): Promise<void> {
|
||||||
|
await page.route("**/api/private-zone/posts**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
characterId: url.searchParams.get("characterId") ?? "maya-tan",
|
||||||
|
items: [videoPost(unlocked)],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
creditBalance: unlocked ? 150 : 250,
|
||||||
|
currency: "credits",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playwright evaluates page routes in reverse registration order. Register
|
||||||
|
// the specific mutation last so the broader list route cannot intercept it.
|
||||||
|
await page.route("**/api/private-zone/posts/*/unlock", async (route) => {
|
||||||
|
unlocked = true;
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
postId,
|
||||||
|
reason: "ok",
|
||||||
|
unlocked: true,
|
||||||
|
creditsCharged: 100,
|
||||||
|
requiredCredits: 100,
|
||||||
|
currentCredits: 150,
|
||||||
|
shortfallCredits: 0,
|
||||||
|
creditBalance: 150,
|
||||||
|
post: videoPost(true),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
albumId: "album-maya",
|
||||||
|
title: "Maya archive",
|
||||||
|
content: null,
|
||||||
|
previewText: "Private photos",
|
||||||
|
imageCount: 1,
|
||||||
|
images: [{ url: "/images/private-zone/banner/maya.png", locked: true, index: 0 }],
|
||||||
|
locked: true,
|
||||||
|
unlocked: false,
|
||||||
|
unlockCost: 80,
|
||||||
|
publishedAt: "2026-07-24T10:00:00+00:00",
|
||||||
|
lockDetail: { locked: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
creditBalance: 250,
|
||||||
|
pendingImageCount: 0,
|
||||||
|
hasIncompleteContent: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoPost(isUnlocked: boolean) {
|
||||||
|
return {
|
||||||
|
postId,
|
||||||
|
characterId: "maya-tan",
|
||||||
|
caption: "Only for you.",
|
||||||
|
posterUrl: "/images/private-zone/banner/maya.png",
|
||||||
|
mediaType: "video",
|
||||||
|
videoUrl: isUnlocked ? "/test-video.mp4?token=short" : null,
|
||||||
|
videoMimeType: "video/mp4",
|
||||||
|
videoSizeBytes: 1024,
|
||||||
|
durationSeconds: 18,
|
||||||
|
unlockCostCredits: 100,
|
||||||
|
currency: "credits",
|
||||||
|
locked: !isUnlocked,
|
||||||
|
unlocked: isUnlocked,
|
||||||
|
availableForNewUnlock: true,
|
||||||
|
publishedAt: "2026-07-24T10:00:00+00:00",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,22 +2,16 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import {
|
import {
|
||||||
paidImageDisplayMessageId,
|
|
||||||
paidImageMessageId,
|
paidImageMessageId,
|
||||||
} from "@e2e/fixtures/test-data";
|
} from "@e2e/fixtures/test-data";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
defaultCharacterChatPath,
|
|
||||||
dismissChatInterruptions,
|
dismissChatInterruptions,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
signInWithEmailAndOpenChat,
|
signInWithEmailAndOpenChat,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
|
||||||
`${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
|
|
||||||
);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
await clearBrowserState(context, page, baseURL);
|
await clearBrowserState(context, page, baseURL);
|
||||||
await mockCoreApis(page, {
|
await mockCoreApis(page, {
|
||||||
@@ -25,30 +19,23 @@ test.beforeEach(async ({ baseURL, context, page }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user sees insufficient credits when unlocking a paid image from fullscreen and can navigate to subscription", async ({
|
test("user sees insufficient credits when unlocking from the locked image card and can navigate to subscription", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await signInWithEmailAndOpenChat(page);
|
await signInWithEmailAndOpenChat(page);
|
||||||
await dismissChatInterruptions(page);
|
await dismissChatInterruptions(page);
|
||||||
|
|
||||||
const paidImageButton = page.getByRole("button", {
|
const lockedImageCard = page.getByRole("group", {
|
||||||
name: "Open image in fullscreen",
|
name: "Locked private image",
|
||||||
}).last();
|
}).last();
|
||||||
await expect(paidImageButton).toBeVisible({ timeout: 10_000 });
|
await expect(lockedImageCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(lockedImageCard.locator("img")).toHaveCount(0);
|
||||||
await paidImageButton.click();
|
|
||||||
await expect(page).toHaveURL(paidImageOverlayUrl);
|
|
||||||
|
|
||||||
const lockedImageDialog = page.getByRole("dialog", {
|
|
||||||
name: "Locked fullscreen image",
|
|
||||||
});
|
|
||||||
await expect(lockedImageDialog).toBeVisible();
|
|
||||||
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await lockedImageDialog
|
await lockedImageCard
|
||||||
.getByRole("button", { name: "Unlock high-definition large image" })
|
.getByRole("button", { name: "Unlock private image" })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
|||||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com
|
NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws
|
||||||
|
NEXT_PUBLIC_ENABLE_VOICE_CALL=false
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||||
|
|
||||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ NEXT_PUBLIC_API_BASE_URL=https://api.banlv-ai.com
|
|||||||
|
|
||||||
# WebSocket URL(生产环境)
|
# WebSocket URL(生产环境)
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws
|
||||||
|
NEXT_PUBLIC_ENABLE_VOICE_CALL=false
|
||||||
|
|
||||||
# Stripe 支付(publishable key 可暴露在浏览器端)
|
# Stripe 支付(publishable key 可暴露在浏览器端)
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 10 MiB |
|
After Width: | Height: | Size: 581 KiB |
|
Before Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 814 KiB After Width: | Height: | Size: 814 KiB |
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 652 KiB |
|
Before Width: | Height: | Size: 907 KiB After Width: | Height: | Size: 907 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>Automatic Renewal Agreement | Cozsweet</title>
|
||||||
|
<style>
|
||||||
|
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
|
||||||
|
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
|
||||||
|
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
|
||||||
|
h2 { margin: 28px 0 8px; font-size: 21px; }
|
||||||
|
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
|
||||||
|
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
|
||||||
|
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Automatic Renewal Agreement</h1>
|
||||||
|
<p class="meta">Effective date: July 29, 2026</p>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
By selecting <strong>Confirm</strong> before payment, you authorize Cozsweet and its payment provider to renew the selected membership automatically under the terms shown below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. When renewal applies</h2>
|
||||||
|
<p>
|
||||||
|
Automatic renewal applies only when the selected membership is identified as auto-renewing in the purchase flow. One-time credit purchases, tips, gifts, lifetime plans, and payment methods explicitly shown as one-time purchases do not automatically renew.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Billing frequency</h2>
|
||||||
|
<p>
|
||||||
|
The plan renews at the end of the billing period shown at checkout, such as monthly, quarterly, or annually. Renewal continues until cancelled. Your payment method may be charged shortly before or on the start of the next period as permitted by the payment provider.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Renewal amount</h2>
|
||||||
|
<p>
|
||||||
|
The initial charge and renewal amount are displayed before confirmation. A promotion may reduce the first charge without reducing later renewals. Unless another renewal amount is shown, renewal uses the applicable regular price for the selected plan, together with any taxes or fees required by law.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Confirmation</h2>
|
||||||
|
<p>
|
||||||
|
Cozsweet asks for renewal confirmation when an account first attempts to pay for an automatically renewing membership in the current agreement version. Cancelling the confirmation does not create an order. Confirming records the acknowledgement in the current browser and continues to payment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Cancellation</h2>
|
||||||
|
<p>
|
||||||
|
You may cancel future renewal using a cancellation option made available in the product or by using the support/payment-issue path before the next renewal is processed. Cancellation takes effect for future billing; membership already paid for normally remains available until the end of the current term.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Failed or disputed payments</h2>
|
||||||
|
<p>
|
||||||
|
A failed renewal can interrupt membership benefits. If you see a duplicate charge, a long-pending payment, or missing benefits, do not repeatedly submit payment. Use <strong>Payment issue?</strong> on the purchase page and keep the returned Feedback ID. Never provide passwords, full card numbers, security codes, verification codes, or access tokens.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Material changes</h2>
|
||||||
|
<p>
|
||||||
|
If the renewal terms materially change, Cozsweet will update this agreement and request a new acknowledgement when required. The version effective for a payment is the version linked from its confirmation dialog.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>VIP Membership Benefits Agreement | Cozsweet</title>
|
||||||
|
<style>
|
||||||
|
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
|
||||||
|
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
|
||||||
|
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
|
||||||
|
h2 { margin: 28px 0 8px; font-size: 21px; }
|
||||||
|
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
|
||||||
|
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
|
||||||
|
a { color: #d8327c; }
|
||||||
|
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>VIP Membership Benefits Agreement</h1>
|
||||||
|
<p class="meta">Effective date: July 29, 2026</p>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
This agreement explains the VIP membership displayed on the Cozsweet purchase page. The plan name, price, currency, duration, credits, and benefits shown at checkout are the terms that apply to your purchase.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Membership benefits</h2>
|
||||||
|
<p>
|
||||||
|
VIP may include the chat access, credits, private content access, or other benefits displayed for the selected plan. Benefits are available only for the membership period and account shown at checkout. A membership does not guarantee that every feature or item is free; some content can still require credits or a separate purchase when clearly shown in the product.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Plan term and account</h2>
|
||||||
|
<p>
|
||||||
|
Membership is attached to the Cozsweet account used for payment. The selected monthly, quarterly, annual, lifetime, or other plan term is shown before payment. Do not share payment credentials or account access with another person.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Automatic renewal</h2>
|
||||||
|
<p>
|
||||||
|
A plan marked as automatically renewing continues until cancelled. Before the first automatically renewing purchase, Cozsweet displays a separate confirmation. Please also read the <a href="/legal/automatic-renewal.html">Automatic Renewal Agreement</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Prices and promotions</h2>
|
||||||
|
<p>
|
||||||
|
The checkout page is the source of truth for the current amount and currency. A first-payment or limited promotion can apply only to the initial charge; the renewal amount may be the regular price displayed in the renewal notice. Cozsweet does not ask you to pay a price that is not shown in the checkout flow.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Payment and access</h2>
|
||||||
|
<p>
|
||||||
|
Benefits are activated only after payment is confirmed and the order is fulfilled. A pending or failed payment does not activate membership. If payment is duplicated, remains pending, or benefits are missing, do not submit repeated payments; use the <strong>Payment issue?</strong> entry on the purchase page and retain the returned Feedback ID.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Cancellation, refunds, and applicable rights</h2>
|
||||||
|
<p>
|
||||||
|
Cancellation stops future renewal but does not normally remove benefits already paid for during the current term. Refund rights depend on applicable law, the payment provider, and the circumstances of the order. Use the product support or payment-issue path for review; never send a password, card number, security code, verification code, or access token.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Changes</h2>
|
||||||
|
<p>
|
||||||
|
If a material membership term changes, the updated version and effective date will be made available before it applies to a new purchase or renewal where required.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
"lang": "en",
|
"lang": "en",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/icons/Icon-192.png",
|
"src": "/images/icons/Icon-192.png?v=20260724",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/icons/Icon-512.png",
|
"src": "/images/icons/Icon-512.png?v=20260724",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ describe("OpenAPI contract comparison", () => {
|
|||||||
chatHistory: { method: "get", path: "/api/chat/history" },
|
chatHistory: { method: "get", path: "/api/chat/history" },
|
||||||
unlockAlbum: {
|
unlockAlbum: {
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/api/private-zoom/albums/{albumId}/unlock",
|
path: "/api/private-zone/albums/{albumId}/unlock",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
paths: {
|
paths: {
|
||||||
"/api/chat/history": { get: {} },
|
"/api/chat/history": { get: {} },
|
||||||
"/api/private-zoom/albums/{album_id}/unlock/": { post: {} },
|
"/api/private-zone/albums/{album_id}/unlock/": { post: {} },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# 推送指定分支到指定远端。
|
# 推送指定分支到指定远端。
|
||||||
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
|
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
|
||||||
# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main"
|
# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main"
|
||||||
# deploy_web_test.sh(test)→ push_to_remote "gitea" "test"
|
# deploy_web_test.sh(pre)→ push_to_remote "gitea" "pre"
|
||||||
push_to_remote() {
|
push_to_remote() {
|
||||||
local remote="$1"
|
local remote="$1"
|
||||||
local branch="$2"
|
local branch="$2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
# 预发环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
||||||
# 原始 Dart: scripts/deploy/deploy_web_test.sh
|
# 原始 Dart: scripts/deploy/deploy_web_test.sh
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh"
|
|||||||
|
|
||||||
# 主函数
|
# 主函数
|
||||||
main() {
|
main() {
|
||||||
push_to_remote "gitea" "test"
|
push_to_remote "gitea" "pre"
|
||||||
}
|
}
|
||||||
|
|
||||||
main
|
main
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ unset_git_hook_env() {
|
|||||||
|
|
||||||
unset_git_hook_env
|
unset_git_hook_env
|
||||||
|
|
||||||
# Test 分支 worktree 路径
|
# pre 分支 worktree 路径
|
||||||
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test"
|
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre"
|
||||||
echo "=== 进入 test 分支 worktree ==="
|
echo "=== 进入 pre 分支 worktree ==="
|
||||||
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
|
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
|
||||||
|
|
||||||
echo "=== 检查当前分支 ==="
|
echo "=== 检查当前分支 ==="
|
||||||
@@ -22,17 +22,17 @@ git branch --show-current
|
|||||||
echo "=== 变基到 dev 分支 ==="
|
echo "=== 变基到 dev 分支 ==="
|
||||||
git rebase dev
|
git rebase dev
|
||||||
|
|
||||||
# 复制测试环境图标(source: $WORKTREE_PATH/icons/test → dest: $WORKTREE_PATH/public/)
|
# 复制预发环境图标(运行资源目录仍为 icons/test)
|
||||||
echo "=== 复制测试环境图标(icons/test → public/) ==="
|
echo "=== 复制预发环境图标(icons/test → public/) ==="
|
||||||
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
|
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
|
||||||
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
|
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
|
||||||
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
|
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
|
||||||
|
|
||||||
# 准备测试环境变量($WORKTREE_PATH/env-example/.env.local.example → $WORKTREE_PATH/.env.local)
|
# 准备预发环境变量(运行配置仍使用 .env.local)
|
||||||
echo "=== 准备测试环境变量(env-example/.env.local.example → .env.local) ==="
|
echo "=== 准备预发环境变量(env-example/.env.local.example → .env.local) ==="
|
||||||
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
|
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
|
||||||
|
|
||||||
echo "=== 执行构建部署脚本(测试环境)==="
|
echo "=== 执行构建部署脚本(预发环境)==="
|
||||||
./scripts/deploy/deploy_web_test.sh
|
./scripts/deploy/deploy_web_test.sh
|
||||||
|
|
||||||
echo "=== 预发布完成 ==="
|
echo "=== 预发布完成 ==="
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ echo "=== compose project: $COMPOSE_PROJECT_NAME ==="
|
|||||||
"${COMPOSE[@]}" up -d --remove-orphans --no-build web
|
"${COMPOSE[@]}" up -d --remove-orphans --no-build web
|
||||||
"${COMPOSE[@]}" ps
|
"${COMPOSE[@]}" ps
|
||||||
|
|
||||||
docker image prune -f
|
|
||||||
|
|
||||||
prune_project_images() {
|
prune_project_images() {
|
||||||
local retain_count="$1"
|
local retain_count="$1"
|
||||||
if ! [[ "$retain_count" =~ ^[0-9]+$ ]] || [ "$retain_count" -le 0 ]; then
|
if ! [[ "$retain_count" =~ ^[0-9]+$ ]] || [ "$retain_count" -le 0 ]; then
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ describe("shared Tailwind components", () => {
|
|||||||
<CharacterAvatar
|
<CharacterAvatar
|
||||||
src="/images/avatar/elio.png"
|
src="/images/avatar/elio.png"
|
||||||
alt="Elio Silvestri"
|
alt="Elio Silvestri"
|
||||||
actionLabel="Open Elio's private zoom"
|
actionLabel="Open Elio's private zone"
|
||||||
analyticsKey="chat.open_private_zoom_from_avatar"
|
analyticsKey="chat.open_private_zone_from_avatar"
|
||||||
onClick={() => undefined}
|
onClick={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -100,10 +100,10 @@ describe("shared Tailwind components", () => {
|
|||||||
|
|
||||||
expect(characterHtml).toContain('<button type="button"');
|
expect(characterHtml).toContain('<button type="button"');
|
||||||
expect(characterHtml).toContain(
|
expect(characterHtml).toContain(
|
||||||
'aria-label="Open Elio's private zoom"',
|
'aria-label="Open Elio's private zone"',
|
||||||
);
|
);
|
||||||
expect(characterHtml).toContain(
|
expect(characterHtml).toContain(
|
||||||
'data-analytics-key="chat.open_private_zoom_from_avatar"',
|
'data-analytics-key="chat.open_private_zone_from_avatar"',
|
||||||
);
|
);
|
||||||
expect(userHtml).toContain('<button type="button"');
|
expect(userHtml).toContain('<button type="button"');
|
||||||
expect(userHtml).toContain('aria-label="Open profile"');
|
expect(userHtml).toContain('aria-label="Open profile"');
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
|
import { pwaUtil } from "@/utils/pwa";
|
||||||
|
|
||||||
|
import { FavoriteEntryButton } from "../favorite-entry-button";
|
||||||
|
|
||||||
|
describe("FavoriteEntryButton install flow", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
window.localStorage.setItem("cozsweet:favorite_entry", "1");
|
||||||
|
vi.spyOn(BrowserDetector, "isInAppBrowser").mockReturnValue(false);
|
||||||
|
vi.spyOn(pwaUtil, "isInstalled").mockReturnValue(false);
|
||||||
|
vi.spyOn(pwaUtil, "prepareInstallPrompt").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(pwaUtil, "subscribe").mockReturnValue(() => undefined);
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Download on iOS and opens Safari add-to-home instructions", () => {
|
||||||
|
vi.spyOn(PlatformDetector, "isIOS").mockReturnValue(true);
|
||||||
|
vi.spyOn(PlatformDetector, "isAndroid").mockReturnValue(false);
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
const downloadButton = getButton("Download");
|
||||||
|
act(() => downloadButton.click());
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Add CozSweet to Home Screen");
|
||||||
|
expect(container.textContent).toContain(
|
||||||
|
"In Safari, tap the Share button, then choose “Add to Home Screen”.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the native Android install prompt and marks an accepted install as Saved", async () => {
|
||||||
|
vi.spyOn(PlatformDetector, "isIOS").mockReturnValue(false);
|
||||||
|
vi.spyOn(PlatformDetector, "isAndroid").mockReturnValue(true);
|
||||||
|
const install = vi.spyOn(pwaUtil, "install").mockResolvedValue("accepted");
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
await act(async () => {
|
||||||
|
getButton("Download").click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(install).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getButton("Saved")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderButton(): void {
|
||||||
|
act(() => {
|
||||||
|
root.render(<FavoriteEntryButton characterSlug="elio" />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButton(label: string): HTMLButtonElement {
|
||||||
|
const button = container.querySelector<HTMLButtonElement>(
|
||||||
|
`button[aria-label="${label}"]`,
|
||||||
|
);
|
||||||
|
if (!button) throw new Error(`Missing button: ${label}`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ModalPortal } from "../modal-portal";
|
||||||
|
|
||||||
|
describe("ModalPortal", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
container.style.transform = "translateX(50%)";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
document.body.style.overflow = "auto";
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("portals outside transformed ancestors and exposes dialog semantics", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<ModalPortal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
scrimClassName="fixed inset-0"
|
||||||
|
panelClassName="payment-panel"
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
role="alertdialog"
|
||||||
|
ariaLabelledBy="payment-title"
|
||||||
|
ariaDescribedBy="payment-description"
|
||||||
|
>
|
||||||
|
<h2 id="payment-title">Complete payment</h2>
|
||||||
|
<p id="payment-description">Choose a payment method.</p>
|
||||||
|
</ModalPortal>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = document.body.querySelector('[role="alertdialog"]');
|
||||||
|
expect(dialog).not.toBeNull();
|
||||||
|
expect(container.contains(dialog)).toBe(false);
|
||||||
|
expect(dialog?.parentElement?.parentElement).toBe(document.body);
|
||||||
|
expect(dialog?.getAttribute("aria-labelledby")).toBe("payment-title");
|
||||||
|
expect(dialog?.getAttribute("aria-describedby")).toBe(
|
||||||
|
"payment-description",
|
||||||
|
);
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the previous body overflow after the final modal closes", () => {
|
||||||
|
const renderModals = (showFirst: boolean, showSecond: boolean) => (
|
||||||
|
<>
|
||||||
|
<ModalPortal
|
||||||
|
open={showFirst}
|
||||||
|
onClose={() => undefined}
|
||||||
|
scrimClassName="first-scrim"
|
||||||
|
panelClassName="first-panel"
|
||||||
|
scrimOpacity={0.4}
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</ModalPortal>
|
||||||
|
<ModalPortal
|
||||||
|
open={showSecond}
|
||||||
|
onClose={() => undefined}
|
||||||
|
scrimClassName="second-scrim"
|
||||||
|
panelClassName="second-panel"
|
||||||
|
scrimOpacity={0.4}
|
||||||
|
>
|
||||||
|
Second
|
||||||
|
</ModalPortal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => root.render(renderModals(true, true)));
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
|
||||||
|
act(() => root.render(renderModals(false, true)));
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
|
||||||
|
act(() => root.render(renderModals(false, false)));
|
||||||
|
expect(document.body.style.overflow).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps persistent payment modals open on scrim click and Escape", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<ModalPortal
|
||||||
|
open
|
||||||
|
persistent
|
||||||
|
onClose={onClose}
|
||||||
|
scrimClassName="fixed inset-0"
|
||||||
|
panelClassName="payment-panel"
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
>
|
||||||
|
Payment
|
||||||
|
</ModalPortal>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrim = document.body.querySelector(".fixed.inset-0");
|
||||||
|
act(() => scrim?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
act(() =>
|
||||||
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,19 +79,19 @@ describe("core Tailwind components", () => {
|
|||||||
<AppBottomNav
|
<AppBottomNav
|
||||||
activeItem="chat"
|
activeItem="chat"
|
||||||
variant="dark"
|
variant="dark"
|
||||||
privateZoomLabel="Maya Private Zoom"
|
privateZoneLabel="Maya Private Zone"
|
||||||
onChatClick={() => undefined}
|
onChatClick={() => undefined}
|
||||||
onPrivateZoomClick={() => undefined}
|
onPrivateZoneClick={() => undefined}
|
||||||
onMenuClick={() => undefined}
|
onMenuClick={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const privateZoomHtml = renderToStaticMarkup(
|
const privateZoneHtml = renderToStaticMarkup(
|
||||||
<AppBottomNav
|
<AppBottomNav
|
||||||
activeItem="privateZoom"
|
activeItem="privateZone"
|
||||||
variant="warm"
|
variant="warm"
|
||||||
privateZoomLabel="Maya Private Zoom"
|
privateZoneLabel="Maya Private Zone"
|
||||||
onChatClick={() => undefined}
|
onChatClick={() => undefined}
|
||||||
onPrivateZoomClick={() => undefined}
|
onPrivateZoneClick={() => undefined}
|
||||||
onMenuClick={() => undefined}
|
onMenuClick={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -101,13 +101,13 @@ describe("core Tailwind components", () => {
|
|||||||
expect(chatHtml).toMatch(
|
expect(chatHtml).toMatch(
|
||||||
/<button[^>]*aria-current="page"[^>]*>.*?<span>Chat<\/span>/,
|
/<button[^>]*aria-current="page"[^>]*>.*?<span>Chat<\/span>/,
|
||||||
);
|
);
|
||||||
expect(privateZoomHtml).toMatch(/class="[^"]*warm[^"]*"/);
|
expect(privateZoneHtml).toMatch(/class="[^"]*warm[^"]*"/);
|
||||||
expect(privateZoomHtml).toMatch(
|
expect(privateZoneHtml).toMatch(
|
||||||
/<button[^>]*aria-current="page"[^>]*>.*?<span>Maya Private Zoom<\/span>/,
|
/<button[^>]*aria-current="page"[^>]*>.*?<span>Maya Private Zone<\/span>/,
|
||||||
);
|
);
|
||||||
expect(chatHtml).toContain('data-analytics-key="navigation.chat"');
|
expect(chatHtml).toContain('data-analytics-key="navigation.chat"');
|
||||||
expect(privateZoomHtml).toContain(
|
expect(privateZoneHtml).toContain(
|
||||||
'data-analytics-key="navigation.private_zoom"',
|
'data-analytics-key="navigation.private_zone"',
|
||||||
);
|
);
|
||||||
expect(chatHtml).toContain('data-analytics-key="navigation.menu"');
|
expect(chatHtml).toContain('data-analytics-key="navigation.menu"');
|
||||||
expect(chatHtml).toContain("<span>Menu</span>");
|
expect(chatHtml).toContain("<span>Menu</span>");
|
||||||
|
|||||||
@@ -4,24 +4,24 @@ import { Camera, Menu as MenuIcon, MessageCircle } from "lucide-react";
|
|||||||
|
|
||||||
import styles from "./app-bottom-nav.module.css";
|
import styles from "./app-bottom-nav.module.css";
|
||||||
|
|
||||||
export type AppBottomNavItem = "chat" | "privateZoom" | "menu";
|
export type AppBottomNavItem = "chat" | "privateZone" | "menu";
|
||||||
export type AppBottomNavVariant = "warm" | "dark";
|
export type AppBottomNavVariant = "warm" | "dark";
|
||||||
|
|
||||||
export interface AppBottomNavProps {
|
export interface AppBottomNavProps {
|
||||||
activeItem?: AppBottomNavItem | null;
|
activeItem?: AppBottomNavItem | null;
|
||||||
variant?: AppBottomNavVariant;
|
variant?: AppBottomNavVariant;
|
||||||
privateZoomLabel: string;
|
privateZoneLabel: string;
|
||||||
onChatClick: () => void;
|
onChatClick: () => void;
|
||||||
onPrivateZoomClick: () => void;
|
onPrivateZoneClick: () => void;
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppBottomNav({
|
export function AppBottomNav({
|
||||||
activeItem = null,
|
activeItem = null,
|
||||||
variant = "warm",
|
variant = "warm",
|
||||||
privateZoomLabel,
|
privateZoneLabel,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
onPrivateZoomClick,
|
onPrivateZoneClick,
|
||||||
onMenuClick,
|
onMenuClick,
|
||||||
}: AppBottomNavProps) {
|
}: AppBottomNavProps) {
|
||||||
return (
|
return (
|
||||||
@@ -39,14 +39,14 @@ export function AppBottomNav({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="navigation.private_zoom"
|
data-analytics-key="navigation.private_zone"
|
||||||
data-analytics-label="Private Zoom navigation"
|
data-analytics-label="Private Zone navigation"
|
||||||
className={getButtonClass(activeItem === "privateZoom")}
|
className={getButtonClass(activeItem === "privateZone")}
|
||||||
aria-current={activeItem === "privateZoom" ? "page" : undefined}
|
aria-current={activeItem === "privateZone" ? "page" : undefined}
|
||||||
onClick={onPrivateZoomClick}
|
onClick={onPrivateZoneClick}
|
||||||
>
|
>
|
||||||
<Camera size={20} aria-hidden="true" />
|
<Camera size={20} aria-hidden="true" />
|
||||||
<span>{privateZoomLabel}</span>
|
<span>{privateZoneLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -78,19 +78,98 @@
|
|||||||
outline-color: #f84d96;
|
outline-color: #f84d96;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.guideBackdrop {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: calc(100% + 8px);
|
inset: 0;
|
||||||
right: 0;
|
z-index: 240;
|
||||||
z-index: 45;
|
display: flex;
|
||||||
width: max-content;
|
align-items: center;
|
||||||
max-width: min(72vw, 250px);
|
justify-content: center;
|
||||||
padding: 8px 10px;
|
padding:
|
||||||
border-radius: 10px;
|
calc(18px + var(--app-safe-top, 0px))
|
||||||
background: rgba(22, 18, 25, 0.94);
|
calc(18px + var(--app-safe-right, 0px))
|
||||||
color: #ffffff;
|
calc(18px + var(--app-safe-bottom, 0px))
|
||||||
font-size: 11px;
|
calc(18px + var(--app-safe-left, 0px));
|
||||||
font-weight: 700;
|
background: rgba(15, 11, 18, 0.58);
|
||||||
line-height: 1.3;
|
backdrop-filter: blur(4px);
|
||||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.24);
|
}
|
||||||
|
|
||||||
|
.guideDialog {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
width: min(100%, 340px);
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 28px 22px 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 250, 252, 0.98);
|
||||||
|
box-shadow: 0 24px 70px rgba(33, 17, 26, 0.3);
|
||||||
|
color: #2d2026;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideClose {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(45, 32, 38, 0.07);
|
||||||
|
color: #5b4a52;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-radius: 19px;
|
||||||
|
background: linear-gradient(135deg, #ff82b2, #f84d96);
|
||||||
|
box-shadow: 0 12px 26px rgba(248, 77, 150, 0.26);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideDialog h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #281a21;
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideDialog p {
|
||||||
|
margin: 12px 0 20px;
|
||||||
|
color: #685761;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideConfirm {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, #ff76ab, #f84d96);
|
||||||
|
box-shadow: 0 10px 24px rgba(248, 77, 150, 0.24);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideClose:focus-visible,
|
||||||
|
.guideConfirm:focus-visible {
|
||||||
|
outline: 2px solid #f84d96;
|
||||||
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { Download, Star } from "lucide-react";
|
import { Download, Share2, Star, X } from "lucide-react";
|
||||||
|
|
||||||
import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser";
|
import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,10 @@ export function FavoriteEntryButton({
|
|||||||
tone = "dark",
|
tone = "dark",
|
||||||
}: FavoriteEntryButtonProps) {
|
}: FavoriteEntryButtonProps) {
|
||||||
const [mode, setMode] = useState<FavoriteEntryMode>("favorite");
|
const [mode, setMode] = useState<FavoriteEntryMode>("favorite");
|
||||||
const [installHint, setInstallHint] = useState(false);
|
const [installGuide, setInstallGuide] = useState<
|
||||||
|
"ios" | "browser" | null
|
||||||
|
>(null);
|
||||||
|
const installGuideTitleId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateMode = () => {
|
const updateMode = () => {
|
||||||
@@ -46,6 +49,15 @@ export function FavoriteEntryButton({
|
|||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!installGuide) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setInstallGuide(null);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [installGuide]);
|
||||||
|
|
||||||
const handleClick = async (): Promise<void> => {
|
const handleClick = async (): Promise<void> => {
|
||||||
if (mode === "favorite") {
|
if (mode === "favorite") {
|
||||||
await openChatInExternalBrowser({ characterSlug });
|
await openChatInExternalBrowser({ characterSlug });
|
||||||
@@ -53,14 +65,18 @@ export function FavoriteEntryButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "download") {
|
if (mode === "download") {
|
||||||
|
if (PlatformDetector.isIOS()) {
|
||||||
|
setInstallGuide("ios");
|
||||||
|
return;
|
||||||
|
}
|
||||||
pwaUtil.prepareInstallPrompt();
|
pwaUtil.prepareInstallPrompt();
|
||||||
const result = await pwaUtil.install();
|
const result = await pwaUtil.install();
|
||||||
if (result === "accepted" || pwaUtil.isInstalled()) {
|
if (result === "accepted" || pwaUtil.isInstalled()) {
|
||||||
setMode("saved");
|
setMode("saved");
|
||||||
setInstallHint(false);
|
setInstallGuide(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result === "unavailable") setInstallHint(true);
|
if (result === "unavailable") setInstallGuide("browser");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,10 +107,49 @@ export function FavoriteEntryButton({
|
|||||||
)}
|
)}
|
||||||
{mode === "favorite" ? null : <span>{label}</span>}
|
{mode === "favorite" ? null : <span>{label}</span>}
|
||||||
</button>
|
</button>
|
||||||
{installHint ? (
|
{installGuide ? (
|
||||||
<span className={styles.hint} role="status">
|
<div
|
||||||
Chrome menu → Add to Home screen
|
className={styles.guideBackdrop}
|
||||||
|
onClick={() => setInstallGuide(null)}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
className={styles.guideDialog}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={installGuideTitleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.guideClose}
|
||||||
|
onClick={() => setInstallGuide(null)}
|
||||||
|
aria-label="Close install instructions"
|
||||||
|
>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span className={styles.guideIcon} aria-hidden="true">
|
||||||
|
{installGuide === "ios" ? (
|
||||||
|
<Share2 size={28} strokeWidth={2.2} />
|
||||||
|
) : (
|
||||||
|
<Download size={28} strokeWidth={2.2} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
<h2 id={installGuideTitleId}>Add CozSweet to Home Screen</h2>
|
||||||
|
<p>
|
||||||
|
{installGuide === "ios"
|
||||||
|
? "In Safari, tap the Share button, then choose “Add to Home Screen”."
|
||||||
|
: "Open your browser menu, then choose “Add to Home screen”."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.guideConfirm}
|
||||||
|
onClick={() => setInstallGuide(null)}
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export interface ModalPortalProps {
|
|||||||
scrimOpacity: number;
|
scrimOpacity: number;
|
||||||
panelStyle?: CSSProperties;
|
panelStyle?: CSSProperties;
|
||||||
persistent?: boolean;
|
persistent?: boolean;
|
||||||
|
role?: "dialog" | "alertdialog";
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
ariaLabelledBy?: string;
|
||||||
|
ariaDescribedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModalPortal({
|
export function ModalPortal({
|
||||||
@@ -32,7 +35,10 @@ export function ModalPortal({
|
|||||||
scrimOpacity,
|
scrimOpacity,
|
||||||
panelStyle,
|
panelStyle,
|
||||||
persistent = false,
|
persistent = false,
|
||||||
|
role = "dialog",
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
ariaLabelledBy,
|
||||||
|
ariaDescribedBy,
|
||||||
}: ModalPortalProps) {
|
}: ModalPortalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof document === "undefined") return;
|
if (!open || typeof document === "undefined") return;
|
||||||
@@ -63,9 +69,11 @@ export function ModalPortal({
|
|||||||
onClick={handleScrimClick}
|
onClick={handleScrimClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role={role}
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledBy}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
className={panelClassName}
|
className={panelClassName}
|
||||||
style={panelStyle}
|
style={panelStyle}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { createCheckoutHandoff, openUrlWithExternalBrowser } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
createCheckoutHandoff: vi.fn(),
|
||||||
|
openUrlWithExternalBrowser: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/checkout_handoff", () => ({ createCheckoutHandoff }));
|
||||||
|
vi.mock("@/utils/url-launcher-util", () => ({
|
||||||
|
UrlLauncherUtil: { openUrlWithExternalBrowser },
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/browser-detect", () => ({
|
||||||
|
BrowserDetector: { isFacebookInAppBrowser: () => true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ExternalBrowserCheckoutButton } from "../external-browser-checkout-button";
|
||||||
|
|
||||||
|
describe("ExternalBrowserCheckoutButton", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
createCheckoutHandoff.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates only a handoff before opening the external browser", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ExternalBrowserCheckoutButton
|
||||||
|
characterSlug="maya"
|
||||||
|
checkoutIntent={{
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector("button");
|
||||||
|
expect(button?.textContent).toContain(
|
||||||
|
"Open in browser for more payment methods",
|
||||||
|
);
|
||||||
|
await act(async () => button?.click());
|
||||||
|
|
||||||
|
expect(createCheckoutHandoff).toHaveBeenCalledWith({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
});
|
||||||
|
expect(openUrlWithExternalBrowser).toHaveBeenCalledWith(
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque&character=maya",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { act } from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
||||||
|
|
||||||
@@ -17,12 +18,32 @@ const hiddenLaunch = {
|
|||||||
shouldShowQrisDialog: false,
|
shouldShowQrisDialog: false,
|
||||||
shouldShowStripeDialog: false,
|
shouldShowStripeDialog: false,
|
||||||
stripeClientSecret: null,
|
stripeClientSecret: null,
|
||||||
|
stripeCustomerSessionClientSecret: null,
|
||||||
|
savedPaymentMethodsEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("PaymentLaunchDialogs", () => {
|
describe("PaymentLaunchDialogs", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
container.style.transform = "translateX(50%)";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
});
|
||||||
|
|
||||||
it("renders nothing when neither payment dialog is active", () => {
|
it("renders nothing when neither payment dialog is active", () => {
|
||||||
expect(
|
act(() =>
|
||||||
renderToStaticMarkup(
|
root.render(
|
||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId={null}
|
currentOrderId={null}
|
||||||
externalCheckoutAnalyticsKey="payment.external_checkout"
|
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||||
@@ -30,11 +51,15 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
launch={hiddenLaunch}
|
launch={hiddenLaunch}
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
).toBe("");
|
);
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the shared Ezpay confirmation content", () => {
|
it("portals the shared Ezpay confirmation outside transformed checkout UI", () => {
|
||||||
const html = renderToStaticMarkup(
|
act(() =>
|
||||||
|
root.render(
|
||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId="order-123"
|
currentOrderId="order-123"
|
||||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||||
@@ -45,18 +70,28 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
shouldShowEzpayConfirmDialog: true,
|
shouldShowEzpayConfirmDialog: true,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('role="alertdialog"');
|
const dialog = document.body.querySelector('[role="alertdialog"]');
|
||||||
expect(html).toContain("Continue to payment?");
|
expect(dialog).not.toBeNull();
|
||||||
expect(html).toContain("Your coffee order is ready.");
|
expect(container.contains(dialog)).toBe(false);
|
||||||
expect(html).toContain("Order No. order-123");
|
expect(dialog?.parentElement?.parentElement).toBe(document.body);
|
||||||
expect(html).toContain('data-analytics-key="tip.external_checkout"');
|
expect(dialog?.textContent).toContain("Continue to payment?");
|
||||||
expect(html).toContain("Opening...");
|
expect(dialog?.textContent).toContain("Your coffee order is ready.");
|
||||||
|
expect(dialog?.textContent).toContain("Order No. order-123");
|
||||||
|
expect(
|
||||||
|
dialog?.querySelector("button[data-analytics-key]")?.getAttribute(
|
||||||
|
"data-analytics-key",
|
||||||
|
),
|
||||||
|
).toBe("tip.external_checkout");
|
||||||
|
expect(dialog?.textContent).toContain("Opening...");
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an accessible QRIS dialog with display cents and order status", () => {
|
it("portals an accessible QRIS dialog with display cents and order status", () => {
|
||||||
const html = renderToStaticMarkup(
|
act(() =>
|
||||||
|
root.render(
|
||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId="order-id-qris"
|
currentOrderId="order-id-qris"
|
||||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||||
@@ -73,19 +108,25 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
shouldShowQrisDialog: true,
|
shouldShowQrisDialog: true,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('role="dialog"');
|
const dialog = document.body.querySelector('[role="dialog"]');
|
||||||
expect(html).toContain("Scan to pay with QRIS");
|
expect(dialog).not.toBeNull();
|
||||||
expect(html).toContain("QRIS payment QR code");
|
expect(container.contains(dialog)).toBe(false);
|
||||||
expect(html).toContain("Order No. order-id-qris");
|
expect(dialog?.textContent).toContain("Scan to pay with QRIS");
|
||||||
expect(html).toContain("Rp");
|
expect(dialog?.querySelector("svg title")?.textContent).toBe(
|
||||||
expect(html).toContain("50.000");
|
"QRIS payment QR code",
|
||||||
expect(html).toContain("Waiting for payment");
|
);
|
||||||
|
expect(dialog?.textContent).toContain("Order No. order-id-qris");
|
||||||
|
expect(dialog?.textContent).toContain("Rp");
|
||||||
|
expect(dialog?.textContent).toContain("50.000");
|
||||||
|
expect(dialog?.textContent).toContain("Waiting for payment");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows QRIS failure state and handles empty QR data safely", () => {
|
it("shows QRIS failure state and handles empty QR data safely", () => {
|
||||||
const html = renderToStaticMarkup(
|
act(() =>
|
||||||
|
root.render(
|
||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId="order-id-qris"
|
currentOrderId="order-id-qris"
|
||||||
externalCheckoutAnalyticsKey="payment.external_checkout"
|
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||||
@@ -103,10 +144,12 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
shouldShowQrisDialog: true,
|
shouldShowQrisDialog: true,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("QRIS data is unavailable");
|
const dialog = document.body.querySelector('[role="dialog"]');
|
||||||
expect(html).toContain("Payment failed or was cancelled.");
|
expect(dialog?.textContent).toContain("QRIS data is unavailable");
|
||||||
expect(html).toContain("Close");
|
expect(dialog?.textContent).toContain("Payment failed or was cancelled.");
|
||||||
|
expect(dialog?.textContent).toContain("Close");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const stripeConfirmPayment = vi.fn(async () => ({}));
|
||||||
|
const elementsSubmit = vi.fn(async () => ({}));
|
||||||
|
let capturedElementsOptions: Record<string, unknown> | null = null;
|
||||||
|
let capturedExpressProps: Record<string, unknown> | null = null;
|
||||||
|
let capturedPaymentProps: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@stripe/stripe-js", () => ({
|
||||||
|
loadStripe: vi.fn(() => Promise.resolve({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@stripe/react-stripe-js", () => ({
|
||||||
|
Elements: ({
|
||||||
|
children,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
capturedElementsOptions = options;
|
||||||
|
return <>{children}</>;
|
||||||
|
},
|
||||||
|
ExpressCheckoutElement: (props: Record<string, unknown>) => {
|
||||||
|
capturedExpressProps = props;
|
||||||
|
return <div data-testid="express-checkout" />;
|
||||||
|
},
|
||||||
|
PaymentElement: (props: Record<string, unknown>) => {
|
||||||
|
capturedPaymentProps = props;
|
||||||
|
return <div data-testid="payment-element" />;
|
||||||
|
},
|
||||||
|
useElements: () => ({ submit: elementsSubmit }),
|
||||||
|
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||||
|
|
||||||
|
describe("StripePaymentDialog", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
capturedElementsOptions = null;
|
||||||
|
capturedExpressProps = null;
|
||||||
|
capturedPaymentProps = null;
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders express methods and wallets before card with English Stripe copy", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
customerSessionClientSecret="cuss_123_secret_saved"
|
||||||
|
savedPaymentMethodsEnabled
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedElementsOptions).toMatchObject({
|
||||||
|
clientSecret: "pi_123_secret_payment",
|
||||||
|
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
expect(capturedExpressProps?.options).toMatchObject({
|
||||||
|
paymentMethodOrder: [
|
||||||
|
"link",
|
||||||
|
"paypal",
|
||||||
|
"amazon_pay",
|
||||||
|
"klarna",
|
||||||
|
],
|
||||||
|
paymentMethods: {
|
||||||
|
applePay: "never",
|
||||||
|
googlePay: "never",
|
||||||
|
link: "auto",
|
||||||
|
paypal: "auto",
|
||||||
|
amazonPay: "auto",
|
||||||
|
klarna: "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(capturedPaymentProps?.options).toMatchObject({
|
||||||
|
layout: { type: "accordion", defaultCollapsed: true },
|
||||||
|
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||||
|
wallets: { applePay: "auto", googlePay: "auto", link: "never" },
|
||||||
|
});
|
||||||
|
const text = document.body.textContent ?? "";
|
||||||
|
expect(text).toContain("Choose a payment method");
|
||||||
|
const express = document.body.querySelector('[data-testid="express-checkout"]');
|
||||||
|
const card = document.body.querySelector('[data-testid="payment-element"]');
|
||||||
|
expect(express).not.toBeNull();
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
express!.compareDocumentPosition(card!) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Payment Element wallet fallbacks when Express Checkout is unavailable", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onReady = capturedExpressProps?.onReady as
|
||||||
|
| ((event: { availablePaymentMethods: undefined }) => void)
|
||||||
|
| undefined;
|
||||||
|
expect(onReady).toBeTypeOf("function");
|
||||||
|
act(() => onReady?.({ availablePaymentMethods: undefined }));
|
||||||
|
|
||||||
|
const expressSection = document.body.querySelector(
|
||||||
|
'[aria-label="Express payment methods"]',
|
||||||
|
);
|
||||||
|
expect(expressSection).toHaveProperty("hidden", true);
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="payment-element"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(capturedPaymentProps?.options).toMatchObject({
|
||||||
|
wallets: { applePay: "auto", googlePay: "auto", link: "never" },
|
||||||
|
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pass an invalid or disabled Customer Session secret", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
customerSessionClientSecret="invalid"
|
||||||
|
savedPaymentMethodsEnabled={false}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedElementsOptions).not.toHaveProperty(
|
||||||
|
"customerSessionClientSecret",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same confirmation path for an Express Checkout approval", async () => {
|
||||||
|
const onConfirmed = vi.fn();
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onConfirmed={onConfirmed}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConfirm = capturedExpressProps?.onConfirm as
|
||||||
|
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
expect(onConfirm).toBeTypeOf("function");
|
||||||
|
await act(async () => {
|
||||||
|
await onConfirm?.({ paymentFailed: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stripeConfirmPayment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
elements: expect.any(Object),
|
||||||
|
redirect: "if_required",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||||
|
import {
|
||||||
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
import { UrlLauncherUtil } from "@/utils/url-launcher-util";
|
||||||
|
|
||||||
|
export interface ExternalBrowserCheckoutButtonProps {
|
||||||
|
checkoutIntent: CheckoutIntent;
|
||||||
|
disabled?: boolean;
|
||||||
|
characterSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalBrowserCheckoutButton({
|
||||||
|
checkoutIntent,
|
||||||
|
disabled = false,
|
||||||
|
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
|
}: ExternalBrowserCheckoutButtonProps) {
|
||||||
|
const isFacebookBrowser = useSyncExternalStore(
|
||||||
|
() => () => undefined,
|
||||||
|
() => BrowserDetector.isFacebookInAppBrowser(),
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const [isOpening, setIsOpening] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!isFacebookBrowser) return null;
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
if (disabled || isOpening) return;
|
||||||
|
setIsOpening(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
const result = await createCheckoutHandoff(checkoutIntent);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
result.error,
|
||||||
|
"Could not open this checkout in your browser. Please sign in and try again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsOpening(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const verifiedCharacter =
|
||||||
|
getCharacterBySlug(characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||||
|
const externalUrl = new URL(
|
||||||
|
result.data.externalUrl,
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
externalUrl.searchParams.set("character", verifiedCharacter);
|
||||||
|
UrlLauncherUtil.openUrlWithExternalBrowser(externalUrl.toString());
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
error,
|
||||||
|
"Could not open this checkout in your browser. Please try again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsOpening(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 w-full text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 w-full cursor-pointer rounded-full border border-black/15 bg-white px-4 py-2.5 text-sm font-semibold text-text-foreground disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
disabled={disabled || isOpening}
|
||||||
|
onClick={() => void handleOpen()}
|
||||||
|
>
|
||||||
|
{isOpening
|
||||||
|
? "Opening browser..."
|
||||||
|
: "Open in browser for more payment methods"}
|
||||||
|
</button>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="mt-2 text-sm text-[#c0392b]" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
|
|
||||||
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
|
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
|
||||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||||
|
|
||||||
@@ -20,22 +22,28 @@ export function LazyStripePaymentDialog(props: StripePaymentDialogProps) {
|
|||||||
return <StripePaymentDialog {...props} />;
|
return <StripePaymentDialog {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StripePaymentDialogLoading() {
|
export function StripePaymentDialogLoading() {
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
role="dialog"
|
persistent
|
||||||
aria-modal="true"
|
onClose={() => undefined}
|
||||||
aria-labelledby="stripe-payment-loading-title"
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
ariaLabelledBy="stripe-payment-loading-title"
|
||||||
|
ariaDescribedBy="stripe-payment-loading-description"
|
||||||
>
|
>
|
||||||
<div className={styles.dialog} aria-busy="true">
|
<div aria-busy="true">
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 id="stripe-payment-loading-title" className={styles.title}>
|
<h2 id="stripe-payment-loading-title" className={styles.title}>
|
||||||
Preparing secure payment
|
Preparing secure payment
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.content}>Loading payment methods...</p>
|
<p id="stripe-payment-loading-description" className={styles.content}>
|
||||||
</div>
|
Loading payment methods...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useId, type ReactNode } from "react";
|
import { useId, type ReactNode } from "react";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
|
||||||
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
|
|
||||||
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
|
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
|
||||||
@@ -23,6 +24,8 @@ type PaymentLaunchDialogFlow = Pick<
|
|||||||
| "shouldShowQrisDialog"
|
| "shouldShowQrisDialog"
|
||||||
| "shouldShowStripeDialog"
|
| "shouldShowStripeDialog"
|
||||||
| "stripeClientSecret"
|
| "stripeClientSecret"
|
||||||
|
| "stripeCustomerSessionClientSecret"
|
||||||
|
| "savedPaymentMethodsEnabled"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface PaymentLaunchDialogsProps {
|
export interface PaymentLaunchDialogsProps {
|
||||||
@@ -63,6 +66,10 @@ export function PaymentLaunchDialogs({
|
|||||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||||
<LazyStripePaymentDialog
|
<LazyStripePaymentDialog
|
||||||
clientSecret={launch.stripeClientSecret}
|
clientSecret={launch.stripeClientSecret}
|
||||||
|
customerSessionClientSecret={
|
||||||
|
launch.stripeCustomerSessionClientSecret
|
||||||
|
}
|
||||||
|
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
|
||||||
returnPath={stripeReturnPath}
|
returnPath={stripeReturnPath}
|
||||||
onClose={launch.handleStripeClose}
|
onClose={launch.handleStripeClose}
|
||||||
onConfirmed={launch.handleStripeConfirmed}
|
onConfirmed={launch.handleStripeConfirmed}
|
||||||
@@ -111,13 +118,15 @@ function QrisPaymentDialog({
|
|||||||
const statusMessage = qrisStatusMessage(status, errorMessage);
|
const statusMessage = qrisStatusMessage(status, errorMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
role="dialog"
|
persistent
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
aria-labelledby={titleId}
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
ariaLabelledBy={titleId}
|
||||||
>
|
>
|
||||||
<div className={styles.dialog}>
|
|
||||||
<div className={`${styles.header} text-center`}>
|
<div className={`${styles.header} text-center`}>
|
||||||
<h2 id={titleId} className={styles.title}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
Scan to pay with QRIS
|
Scan to pay with QRIS
|
||||||
@@ -163,8 +172,7 @@ function QrisPaymentDialog({
|
|||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalPortal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,13 +196,16 @@ function EzpayRedirectConfirmDialog({
|
|||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
|
persistent
|
||||||
|
onClose={onCancel}
|
||||||
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
ariaLabelledBy={titleId}
|
||||||
aria-labelledby={titleId}
|
|
||||||
>
|
>
|
||||||
<div className={styles.dialog}>
|
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 id={titleId} className={styles.title}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
Continue to payment?
|
Continue to payment?
|
||||||
@@ -222,7 +233,6 @@ function EzpayRedirectConfirmDialog({
|
|||||||
{isConfirming ? "Opening..." : "Continue"}
|
{isConfirming ? "Opening..." : "Continue"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalPortal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ export const stripePaymentDialogStyles = {
|
|||||||
overlay:
|
overlay:
|
||||||
"fixed inset-0 z-70 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-bottom,0))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0))]",
|
"fixed inset-0 z-70 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-bottom,0))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0))]",
|
||||||
dialog:
|
dialog:
|
||||||
"max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]",
|
"max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto overscroll-contain rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]",
|
||||||
header: "mb-(--page-section-gap,18px) text-left",
|
header: "mb-(--page-section-gap,18px) text-left",
|
||||||
title:
|
title:
|
||||||
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-text-foreground",
|
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-text-foreground",
|
||||||
content:
|
content:
|
||||||
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
||||||
form: "flex flex-col gap-(--page-section-gap,18px)",
|
form: "flex flex-col gap-(--page-section-gap,18px)",
|
||||||
|
expressSection: "w-full empty:hidden",
|
||||||
|
cardSection:
|
||||||
|
"flex w-full flex-col gap-(--spacing-sm,8px) border-t border-black/10 pt-(--page-section-gap,18px)",
|
||||||
|
cardTitle:
|
||||||
|
"m-0 text-(length:--responsive-body,16px) font-semibold text-text-foreground",
|
||||||
error:
|
error:
|
||||||
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
||||||
actions: "flex w-full gap-(--spacing-md,12px)",
|
actions: "flex w-full gap-(--spacing-md,12px)",
|
||||||
|
|||||||
@@ -4,18 +4,28 @@
|
|||||||
*
|
*
|
||||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||||
*/
|
*/
|
||||||
import { useState, type FormEvent } from "react";
|
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Elements,
|
Elements,
|
||||||
|
ExpressCheckoutElement,
|
||||||
PaymentElement,
|
PaymentElement,
|
||||||
useElements,
|
useElements,
|
||||||
useStripe,
|
useStripe,
|
||||||
} from "@stripe/react-stripe-js";
|
} from "@stripe/react-stripe-js";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import {
|
||||||
|
loadStripe,
|
||||||
|
type AvailablePaymentMethods,
|
||||||
|
type StripeExpressCheckoutElementConfirmEvent,
|
||||||
|
} from "@stripe/stripe-js";
|
||||||
|
|
||||||
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
import { ExceptionHandler } from "@/core/errors";
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
|
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
|
||||||
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||||
|
|
||||||
@@ -25,8 +35,16 @@ const stripePromise = stripePublishableKey
|
|||||||
? loadStripe(stripePublishableKey)
|
? loadStripe(stripePublishableKey)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
|
||||||
|
|
||||||
|
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
|
||||||
|
return value === true || (typeof value === "object" && value.available);
|
||||||
|
}
|
||||||
|
|
||||||
export interface StripePaymentDialogProps {
|
export interface StripePaymentDialogProps {
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
|
customerSessionClientSecret?: string | null;
|
||||||
|
savedPaymentMethodsEnabled?: boolean;
|
||||||
returnPath?: string;
|
returnPath?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmed?: () => void;
|
onConfirmed?: () => void;
|
||||||
@@ -34,17 +52,38 @@ export interface StripePaymentDialogProps {
|
|||||||
|
|
||||||
export function StripePaymentDialog({
|
export function StripePaymentDialog({
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
customerSessionClientSecret = null,
|
||||||
|
savedPaymentMethodsEnabled = false,
|
||||||
returnPath = ROUTES.subscription + "/success",
|
returnPath = ROUTES.subscription + "/success",
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmed,
|
onConfirmed,
|
||||||
}: StripePaymentDialogProps) {
|
}: StripePaymentDialogProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
|
||||||
|
provider: "stripe",
|
||||||
|
customerSessionClientSecret,
|
||||||
|
savedPaymentMethodsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
if (!stripePromise) {
|
if (!stripePromise) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.overlay} role="alertdialog" aria-modal="true">
|
<ModalPortal
|
||||||
<div className={styles.dialog}>
|
open
|
||||||
|
persistent
|
||||||
|
onClose={onClose}
|
||||||
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
role="alertdialog"
|
||||||
|
ariaLabelledBy={titleId}
|
||||||
|
ariaDescribedBy={descriptionId}
|
||||||
|
>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 className={styles.title}>Payment unavailable</h2>
|
<h2 id={titleId} className={styles.title}>
|
||||||
<p className={styles.content}>
|
Payment unavailable
|
||||||
|
</h2>
|
||||||
|
<p id={descriptionId} className={styles.content}>
|
||||||
Stripe publishable key is not configured for this build.
|
Stripe publishable key is not configured for this build.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,24 +96,26 @@ export function StripePaymentDialog({
|
|||||||
OK
|
OK
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalPortal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
role="dialog"
|
persistent
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
aria-labelledby="stripe-payment-title"
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
ariaLabelledBy={titleId}
|
||||||
|
ariaDescribedBy={descriptionId}
|
||||||
>
|
>
|
||||||
<div className={styles.dialog}>
|
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 id="stripe-payment-title" className={styles.title}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
Complete payment
|
Complete payment
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.content}>
|
<p id={descriptionId} className={styles.content}>
|
||||||
Enter your payment details securely through Stripe.
|
Enter your payment details securely through Stripe.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +124,10 @@ export function StripePaymentDialog({
|
|||||||
stripe={stripePromise}
|
stripe={stripePromise}
|
||||||
options={{
|
options={{
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
locale: "en",
|
||||||
|
...(savedCardClientSecret
|
||||||
|
? { customerSessionClientSecret: savedCardClientSecret }
|
||||||
|
: {}),
|
||||||
appearance: {
|
appearance: {
|
||||||
theme: "stripe",
|
theme: "stripe",
|
||||||
variables: {
|
variables: {
|
||||||
@@ -99,8 +144,7 @@ export function StripePaymentDialog({
|
|||||||
onConfirmed={onConfirmed}
|
onConfirmed={onConfirmed}
|
||||||
/>
|
/>
|
||||||
</Elements>
|
</Elements>
|
||||||
</div>
|
</ModalPortal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +160,20 @@ function StripePaymentForm({
|
|||||||
const elements = useElements();
|
const elements = useElements();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState<
|
||||||
|
boolean | null
|
||||||
|
>(null);
|
||||||
|
const [expressMaxColumns, setExpressMaxColumns] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") return;
|
||||||
|
const query = window.matchMedia("(min-width: 640px)");
|
||||||
|
const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1);
|
||||||
|
updateColumns();
|
||||||
|
query.addEventListener("change", updateColumns);
|
||||||
|
return () => query.removeEventListener("change", updateColumns);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLoadError = (event: Parameters<
|
const handleLoadError = (event: Parameters<
|
||||||
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
||||||
@@ -134,38 +192,118 @@ function StripePaymentForm({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const updateExpressAvailability = (
|
||||||
event.preventDefault();
|
availablePaymentMethods:
|
||||||
if (!stripe || !elements || isSubmitting) return;
|
| AvailablePaymentMethods
|
||||||
|
| {
|
||||||
|
applePay?: { available: boolean };
|
||||||
|
googlePay?: { available: boolean };
|
||||||
|
link?: { available: boolean };
|
||||||
|
paypal?: { available: boolean };
|
||||||
|
amazonPay?: { available: boolean };
|
||||||
|
klarna?: { available: boolean };
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) => {
|
||||||
|
const availability = {
|
||||||
|
applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay),
|
||||||
|
googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay),
|
||||||
|
link: isExpressMethodAvailable(availablePaymentMethods?.link),
|
||||||
|
paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal),
|
||||||
|
amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay),
|
||||||
|
klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna),
|
||||||
|
};
|
||||||
|
setHasExpressPaymentMethods(Object.values(availability).some(Boolean));
|
||||||
|
const browser = BrowserDetector.isFacebookInAppBrowser()
|
||||||
|
? "facebook"
|
||||||
|
: BrowserDetector.getBrowserName() || "unknown";
|
||||||
|
const platform = PlatformDetector.getPlatform();
|
||||||
|
const metadata = {
|
||||||
|
browser,
|
||||||
|
platform,
|
||||||
|
availablePaymentMethods: availability,
|
||||||
|
};
|
||||||
|
log.info(
|
||||||
|
"[stripe-payment-dialog] Express Checkout availability",
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"stripe.express_checkout_availability",
|
||||||
|
"Stripe Express Checkout availability",
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExpressLoadError = (event: Parameters<
|
||||||
|
NonNullable<
|
||||||
|
React.ComponentProps<typeof ExpressCheckoutElement>["onLoadError"]
|
||||||
|
>
|
||||||
|
>[0]) => {
|
||||||
|
setHasExpressPaymentMethods(false);
|
||||||
|
log.warn("[stripe-payment-dialog] Express Checkout load failed", {
|
||||||
|
type: event.error.type,
|
||||||
|
code: event.error.code,
|
||||||
|
message: event.error.message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPayment = async (
|
||||||
|
options: {
|
||||||
|
submitPaymentElement: boolean;
|
||||||
|
expressEvent?: StripeExpressCheckoutElementConfirmEvent;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (!stripe || !elements || submittingRef.current) return;
|
||||||
|
|
||||||
|
submittingRef.current = true;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
const { error: submitError } = await elements.submit();
|
if (options.submitPaymentElement) {
|
||||||
|
let submitError: unknown;
|
||||||
|
try {
|
||||||
|
({ error: submitError } = await elements.submit());
|
||||||
|
} catch (error) {
|
||||||
|
submitError = error;
|
||||||
|
}
|
||||||
if (submitError) {
|
if (submitError) {
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
ExceptionHandler.message(submitError, "Please check your payment info."),
|
ExceptionHandler.message(
|
||||||
|
submitError,
|
||||||
|
"Please check your payment info.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
submittingRef.current = false;
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const returnUrl = new URL(
|
const returnUrl = new URL(
|
||||||
returnPath ?? ROUTES.subscription + "/success",
|
returnPath ?? ROUTES.subscription + "/success",
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const { error } = await stripe.confirmPayment({
|
let confirmationError: unknown;
|
||||||
|
try {
|
||||||
|
({ error: confirmationError } = await stripe.confirmPayment({
|
||||||
elements,
|
elements,
|
||||||
confirmParams: {
|
confirmParams: {
|
||||||
return_url: returnUrl.toString(),
|
return_url: returnUrl.toString(),
|
||||||
},
|
},
|
||||||
redirect: "if_required",
|
redirect: "if_required",
|
||||||
});
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
confirmationError = error;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (confirmationError) {
|
||||||
setErrorMessage(
|
const message = ExceptionHandler.message(
|
||||||
ExceptionHandler.message(error, "Payment confirmation failed."),
|
confirmationError,
|
||||||
|
"Payment confirmation failed.",
|
||||||
);
|
);
|
||||||
|
setErrorMessage(message);
|
||||||
|
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
||||||
|
submittingRef.current = false;
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,9 +311,75 @@ function StripePaymentForm({
|
|||||||
onConfirmed?.();
|
onConfirmed?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await confirmPayment({ submitPaymentElement: true });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={styles.form} onSubmit={handleSubmit}>
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
<PaymentElement onLoadError={handleLoadError} />
|
<section
|
||||||
|
className={styles.expressSection}
|
||||||
|
hidden={hasExpressPaymentMethods === false}
|
||||||
|
aria-label="Express payment methods"
|
||||||
|
>
|
||||||
|
<ExpressCheckoutElement
|
||||||
|
options={{
|
||||||
|
paymentMethodOrder: [
|
||||||
|
"link",
|
||||||
|
"paypal",
|
||||||
|
"amazon_pay",
|
||||||
|
"klarna",
|
||||||
|
],
|
||||||
|
paymentMethods: {
|
||||||
|
applePay: "never",
|
||||||
|
googlePay: "never",
|
||||||
|
link: "auto",
|
||||||
|
paypal: "auto",
|
||||||
|
amazonPay: "auto",
|
||||||
|
klarna: "auto",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
maxColumns: expressMaxColumns,
|
||||||
|
maxRows: 6,
|
||||||
|
overflow: "never",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onReady={(event) =>
|
||||||
|
updateExpressAvailability(event.availablePaymentMethods)
|
||||||
|
}
|
||||||
|
onAvailablePaymentMethodsChange={(event) =>
|
||||||
|
updateExpressAvailability(event.paymentMethods)
|
||||||
|
}
|
||||||
|
onLoadError={handleExpressLoadError}
|
||||||
|
onConfirm={(event) =>
|
||||||
|
void confirmPayment({
|
||||||
|
submitPaymentElement: false,
|
||||||
|
expressEvent: event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
className={styles.cardSection}
|
||||||
|
aria-labelledby="payment-method-title"
|
||||||
|
>
|
||||||
|
<h3 id="payment-method-title" className={styles.cardTitle}>
|
||||||
|
Choose a payment method
|
||||||
|
</h3>
|
||||||
|
<PaymentElement
|
||||||
|
options={{
|
||||||
|
layout: { type: "accordion", defaultCollapsed: true },
|
||||||
|
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||||
|
wallets: {
|
||||||
|
applePay: "auto",
|
||||||
|
googlePay: "auto",
|
||||||
|
link: "never",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onLoadError={handleLoadError}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPaymentUrl,
|
getPaymentUrl,
|
||||||
|
getStripeCustomerSessionClientSecret,
|
||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
launchEzpayRedirect,
|
launchEzpayRedirect,
|
||||||
@@ -48,8 +49,10 @@ export interface PaymentLaunchFlow {
|
|||||||
handleEzpayCancel: () => void;
|
handleEzpayCancel: () => void;
|
||||||
handleEzpayConfirm: () => void;
|
handleEzpayConfirm: () => void;
|
||||||
handleQrisClose: () => void;
|
handleQrisClose: () => void;
|
||||||
|
handleQrisResume: () => void;
|
||||||
handleStripeClose: () => void;
|
handleStripeClose: () => void;
|
||||||
handleStripeConfirmed: () => void;
|
handleStripeConfirmed: () => void;
|
||||||
|
hasHiddenQrisPayment: boolean;
|
||||||
isConfirmingEzpay: boolean;
|
isConfirmingEzpay: boolean;
|
||||||
qrisErrorMessage: string | null;
|
qrisErrorMessage: string | null;
|
||||||
qrisPayment: QrisPaymentDetails | null;
|
qrisPayment: QrisPaymentDetails | null;
|
||||||
@@ -59,6 +62,8 @@ export interface PaymentLaunchFlow {
|
|||||||
shouldShowQrisDialog: boolean;
|
shouldShowQrisDialog: boolean;
|
||||||
shouldShowStripeDialog: boolean;
|
shouldShowStripeDialog: boolean;
|
||||||
stripeClientSecret: string | null;
|
stripeClientSecret: string | null;
|
||||||
|
stripeCustomerSessionClientSecret: string | null;
|
||||||
|
savedPaymentMethodsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QrisPaymentDetails {
|
export interface QrisPaymentDetails {
|
||||||
@@ -165,6 +170,9 @@ export function usePaymentLaunchFlow({
|
|||||||
const stripeClientSecret = payment.payParams
|
const stripeClientSecret = payment.payParams
|
||||||
? getStripeClientSecret(payment.payParams)
|
? getStripeClientSecret(payment.payParams)
|
||||||
: null;
|
: null;
|
||||||
|
const stripeCustomerSessionClientSecret = payment.payParams
|
||||||
|
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||||
|
: null;
|
||||||
const ezpayLaunchTarget =
|
const ezpayLaunchTarget =
|
||||||
payment.payParams && isEzpayPayment(payment.payParams)
|
payment.payParams && isEzpayPayment(payment.payParams)
|
||||||
? resolveEzpayLaunchTarget(payment.payParams)
|
? resolveEzpayLaunchTarget(payment.payParams)
|
||||||
@@ -310,6 +318,12 @@ export function usePaymentLaunchFlow({
|
|||||||
qrisPayment.orderId !== hiddenQrisOrderId &&
|
qrisPayment.orderId !== hiddenQrisOrderId &&
|
||||||
!payment.isPaid,
|
!payment.isPaid,
|
||||||
);
|
);
|
||||||
|
const hasHiddenQrisPayment = Boolean(
|
||||||
|
qrisPayment &&
|
||||||
|
qrisPayment.orderId === hiddenQrisOrderId &&
|
||||||
|
payment.isPollingOrder &&
|
||||||
|
!payment.isPaid,
|
||||||
|
);
|
||||||
|
|
||||||
function resetLaunchState(): void {
|
function resetLaunchState(): void {
|
||||||
setIsConfirmingEzpay(false);
|
setIsConfirmingEzpay(false);
|
||||||
@@ -369,13 +383,24 @@ export function usePaymentLaunchFlow({
|
|||||||
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleQrisResume(): void {
|
||||||
|
if (!hasHiddenQrisPayment) return;
|
||||||
|
log.debug(`[${logScope}] qris dialog resumed`, {
|
||||||
|
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
||||||
|
subscriptionType,
|
||||||
|
});
|
||||||
|
setHiddenQrisOrderId(null);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ezpayPaymentUrl,
|
ezpayPaymentUrl,
|
||||||
handleEzpayCancel,
|
handleEzpayCancel,
|
||||||
handleEzpayConfirm,
|
handleEzpayConfirm,
|
||||||
handleQrisClose,
|
handleQrisClose,
|
||||||
|
handleQrisResume,
|
||||||
handleStripeClose,
|
handleStripeClose,
|
||||||
handleStripeConfirmed,
|
handleStripeConfirmed,
|
||||||
|
hasHiddenQrisPayment,
|
||||||
isConfirmingEzpay,
|
isConfirmingEzpay,
|
||||||
qrisErrorMessage: payment.errorMessage,
|
qrisErrorMessage: payment.errorMessage,
|
||||||
qrisPayment,
|
qrisPayment,
|
||||||
@@ -385,5 +410,8 @@ export function usePaymentLaunchFlow({
|
|||||||
shouldShowQrisDialog,
|
shouldShowQrisDialog,
|
||||||
shouldShowStripeDialog,
|
shouldShowStripeDialog,
|
||||||
stripeClientSecret,
|
stripeClientSecret,
|
||||||
|
stripeCustomerSessionClientSecret,
|
||||||
|
savedPaymentMethodsEnabled:
|
||||||
|
stripeCustomerSessionClientSecret !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface UsePaymentRouteFlowInput {
|
|||||||
characterId?: string;
|
characterId?: string;
|
||||||
initialCategory?: string | null;
|
initialCategory?: string | null;
|
||||||
initialPlanId?: string | null;
|
initialPlanId?: string | null;
|
||||||
|
initialAutoRenew?: boolean | null;
|
||||||
|
commercialOfferId?: string | null;
|
||||||
|
resumeOrderId?: string | null;
|
||||||
|
chatActionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentRouteFlow {
|
export interface PaymentRouteFlow {
|
||||||
@@ -41,15 +45,23 @@ export function usePaymentRouteFlow({
|
|||||||
characterId,
|
characterId,
|
||||||
initialCategory = null,
|
initialCategory = null,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
|
initialAutoRenew = null,
|
||||||
|
commercialOfferId = null,
|
||||||
|
resumeOrderId = null,
|
||||||
|
chatActionId = null,
|
||||||
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
|
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const initializedCatalogKeyRef = useRef<string | null>(null);
|
const initializedCatalogKeyRef = useRef<string | null>(null);
|
||||||
|
const resumedOrderIdRef = useRef<string | null>(null);
|
||||||
const catalogKey = [
|
const catalogKey = [
|
||||||
catalog,
|
catalog,
|
||||||
characterId ?? "",
|
characterId ?? "",
|
||||||
initialCategory ?? "",
|
initialCategory ?? "",
|
||||||
initialPlanId ?? "",
|
initialPlanId ?? "",
|
||||||
|
initialAutoRenew === null ? "" : String(initialAutoRenew),
|
||||||
|
commercialOfferId ?? "",
|
||||||
|
chatActionId ?? "",
|
||||||
].join(":");
|
].join(":");
|
||||||
|
|
||||||
usePendingPaymentOrderLifecycle({
|
usePendingPaymentOrderLifecycle({
|
||||||
@@ -69,16 +81,30 @@ export function usePaymentRouteFlow({
|
|||||||
...(characterId ? { characterId } : {}),
|
...(characterId ? { characterId } : {}),
|
||||||
...(initialCategory ? { category: initialCategory } : {}),
|
...(initialCategory ? { category: initialCategory } : {}),
|
||||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||||
|
...(initialAutoRenew === null
|
||||||
|
? {}
|
||||||
|
: { autoRenew: initialAutoRenew }),
|
||||||
|
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||||
|
...(chatActionId ? { chatActionId } : {}),
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
catalog,
|
catalog,
|
||||||
catalogKey,
|
catalogKey,
|
||||||
characterId,
|
characterId,
|
||||||
|
commercialOfferId,
|
||||||
|
chatActionId,
|
||||||
initialCategory,
|
initialCategory,
|
||||||
initialPlanId,
|
initialPlanId,
|
||||||
|
initialAutoRenew,
|
||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resumeOrderId || resumedOrderIdRef.current === resumeOrderId) return;
|
||||||
|
resumedOrderIdRef.current = resumeOrderId;
|
||||||
|
paymentDispatch({ type: "PaymentReturned", orderId: resumeOrderId });
|
||||||
|
}, [paymentDispatch, resumeOrderId]);
|
||||||
|
|
||||||
return { payment, paymentDispatch };
|
return { payment, paymentDispatch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { VoiceCallPlayer } from "@/app/call/voice-call-player";
|
||||||
|
|
||||||
|
describe("VoiceCallPlayer", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules decoded sentence audio on one continuous Web Audio timeline", async () => {
|
||||||
|
const starts: number[] = [];
|
||||||
|
const sources: Array<{ onended: (() => void) | null }> = [];
|
||||||
|
const context = {
|
||||||
|
state: "running",
|
||||||
|
currentTime: 0,
|
||||||
|
destination: {},
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
decodeAudioData: vi.fn(async () => ({ duration: 1 })),
|
||||||
|
createBufferSource: vi.fn(() => {
|
||||||
|
const source = {
|
||||||
|
buffer: null,
|
||||||
|
onended: null as (() => void) | null,
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
start: vi.fn((at: number) => starts.push(at)),
|
||||||
|
};
|
||||||
|
sources.push(source);
|
||||||
|
return source;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.stubGlobal("AudioContext", function AudioContextMock() {
|
||||||
|
return context;
|
||||||
|
});
|
||||||
|
|
||||||
|
const player = new VoiceCallPlayer();
|
||||||
|
const gaps: number[] = [];
|
||||||
|
player.onGap = (milliseconds) => gaps.push(milliseconds);
|
||||||
|
const meta = {
|
||||||
|
callId: "call-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
index: 0,
|
||||||
|
mimeType: "audio/mpeg",
|
||||||
|
byteLength: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
player.enqueue(meta, new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
player.enqueue({ ...meta, index: 1 }, new Uint8Array([4, 5, 6]).buffer);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(starts).toHaveLength(2));
|
||||||
|
expect(starts).toEqual([0.025, 1.025]);
|
||||||
|
expect(gaps).toEqual([0]);
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
player.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
|
|
||||||
|
export default function LegacyCallPage() {
|
||||||
|
redirect(getCharacterRoutes(DEFAULT_CHARACTER_SLUG).call);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { CallAudioChunkMeta } from "@/core/net/voice-call-transport";
|
||||||
|
|
||||||
|
export class VoiceCallPlayer {
|
||||||
|
private context: AudioContext | null = null;
|
||||||
|
private sources = new Set<AudioBufferSourceNode>();
|
||||||
|
private decodeChain: Promise<void> = Promise.resolve();
|
||||||
|
private scheduledUntil = 0;
|
||||||
|
private pendingDecodes = 0;
|
||||||
|
private generation = 0;
|
||||||
|
onPlaying: (() => void) | null = null;
|
||||||
|
onIdle: (() => void) | null = null;
|
||||||
|
onGap: ((milliseconds: number) => void) | null = null;
|
||||||
|
onError: ((error: Error) => void) | null = null;
|
||||||
|
|
||||||
|
get isPlaying(): boolean {
|
||||||
|
return this.sources.size > 0 || this.pendingDecodes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepare(): Promise<void> {
|
||||||
|
const context = this.getContext();
|
||||||
|
if (context.state === "suspended") await context.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(_meta: CallAudioChunkMeta, bytes: ArrayBuffer): void {
|
||||||
|
const generation = this.generation;
|
||||||
|
this.pendingDecodes += 1;
|
||||||
|
this.decodeChain = this.decodeChain
|
||||||
|
.then(async () => {
|
||||||
|
const context = this.getContext();
|
||||||
|
if (context.state === "suspended") await context.resume();
|
||||||
|
const audioBuffer = await context.decodeAudioData(bytes.slice(0));
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
this.schedule(context, audioBuffer);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
this.onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.pendingDecodes = Math.max(0, this.pendingDecodes - 1);
|
||||||
|
this.notifyIdleIfNeeded();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.generation += 1;
|
||||||
|
for (const source of this.sources) {
|
||||||
|
source.onended = null;
|
||||||
|
try {
|
||||||
|
source.stop();
|
||||||
|
} catch {
|
||||||
|
// 已自然结束的 source 无需重复停止。
|
||||||
|
}
|
||||||
|
source.disconnect();
|
||||||
|
}
|
||||||
|
this.sources.clear();
|
||||||
|
this.scheduledUntil = 0;
|
||||||
|
this.onIdle?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stop();
|
||||||
|
const context = this.context;
|
||||||
|
this.context = null;
|
||||||
|
if (context && context.state !== "closed") void context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContext(): AudioContext {
|
||||||
|
if (!this.context || this.context.state === "closed") {
|
||||||
|
this.context = new AudioContext({ latencyHint: "interactive" });
|
||||||
|
}
|
||||||
|
return this.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(context: AudioContext, buffer: AudioBuffer): void {
|
||||||
|
const leadSeconds = 0.025;
|
||||||
|
const earliestStart = context.currentTime + leadSeconds;
|
||||||
|
const hadPreviousSegment = this.scheduledUntil > 0;
|
||||||
|
const startAt = Math.max(earliestStart, this.scheduledUntil);
|
||||||
|
if (hadPreviousSegment) {
|
||||||
|
this.onGap?.(Math.max(0, (startAt - this.scheduledUntil) * 1000));
|
||||||
|
}
|
||||||
|
const source = context.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(context.destination);
|
||||||
|
this.sources.add(source);
|
||||||
|
this.scheduledUntil = startAt + buffer.duration;
|
||||||
|
source.onended = () => {
|
||||||
|
source.disconnect();
|
||||||
|
this.sources.delete(source);
|
||||||
|
this.notifyIdleIfNeeded();
|
||||||
|
};
|
||||||
|
source.start(startAt);
|
||||||
|
this.onPlaying?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyIdleIfNeeded(): void {
|
||||||
|
if (this.sources.size || this.pendingDecodes) return;
|
||||||
|
this.scheduledUntil = 0;
|
||||||
|
this.onIdle?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--app-viewport-height, 100dvh);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #fff;
|
||||||
|
background: #090810;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background { object-fit: cover; opacity: .48; }
|
||||||
|
.scrim { position: absolute; inset: 0; background: radial-gradient(circle at 50% 32%, rgba(239,83,155,.18), transparent 36%), linear-gradient(180deg, rgba(9,8,16,.28), rgba(9,8,16,.94)); }
|
||||||
|
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: calc(18px + var(--app-safe-top, 0px)) 20px 12px; }
|
||||||
|
.iconButton { display: inline-flex; width: 42px; height: 42px; cursor: pointer; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,.16); border-radius: 999px; color: #fff; background: rgba(10,9,18,.55); backdrop-filter: blur(14px); }
|
||||||
|
.balances { display: flex; gap: 8px; font-size: 12px; font-weight: 650; }
|
||||||
|
.balances span { padding: 7px 10px; border: 1px solid rgba(255,255,255,.12); border-radius: 999px; background: rgba(10,9,18,.58); backdrop-filter: blur(14px); }
|
||||||
|
.content { position: relative; z-index: 1; display: flex; min-height: 0; flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 24px 28px; text-align: center; }
|
||||||
|
.avatarRing { position: relative; display: grid; width: 174px; height: 174px; place-items: center; border-radius: 50%; background: linear-gradient(135deg, rgba(255,255,255,.24), rgba(239,83,155,.42)); box-shadow: 0 28px 72px rgba(0,0,0,.42); transition: transform .25s ease, box-shadow .25s ease; }
|
||||||
|
.avatarRing::after { position: absolute; inset: -10px; border: 1px solid rgba(255,255,255,.16); border-radius: inherit; content: ""; }
|
||||||
|
.avatarRing.listening, .avatarRing.speaking { animation: callPulse 1.8s ease-in-out infinite; box-shadow: 0 0 0 12px rgba(239,83,155,.08), 0 28px 72px rgba(0,0,0,.42); }
|
||||||
|
.avatar { width: 158px; height: 158px; border: 4px solid rgba(9,8,16,.8); border-radius: 50%; object-fit: cover; }
|
||||||
|
.content h1 { margin: 30px 0 8px; font-size: clamp(27px, 7vw, 36px); letter-spacing: -.025em; }
|
||||||
|
.state { display: flex; min-height: 28px; align-items: center; gap: 7px; color: rgba(255,255,255,.78); font-size: 15px; }
|
||||||
|
.lowBalance { margin: 12px 0 0; padding: 7px 11px; border-radius: 999px; color: #ffd6e8; background: rgba(210,48,121,.2); font-size: 12px; }
|
||||||
|
.transcript { width: min(100%, 440px); min-height: 105px; margin-top: 24px; text-align: left; }
|
||||||
|
.transcript p { margin: 0 0 10px; padding: 11px 13px; border: 1px solid rgba(255,255,255,.09); border-radius: 15px; color: rgba(255,255,255,.9); background: rgba(14,12,24,.48); font-size: 14px; line-height: 1.45; backdrop-filter: blur(12px); }
|
||||||
|
.transcript span { display: block; margin-bottom: 4px; color: #f3a3c8; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||||
|
.error { max-width: 360px; margin: 8px 0 0; color: #ffc2ca; font-size: 13px; }
|
||||||
|
.footer { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; padding: 10px 24px calc(24px + var(--app-safe-bottom, 0px)); }
|
||||||
|
.footer p { margin: 12px 0 0; color: rgba(255,255,255,.52); font-size: 12px; }
|
||||||
|
.startButton { display: inline-flex; min-width: 170px; cursor: pointer; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 999px; padding: 15px 24px; color: #fff; background: linear-gradient(135deg, #ee579d, #ce357c); box-shadow: 0 16px 36px rgba(212,54,127,.32); font-size: 15px; font-weight: 750; }
|
||||||
|
.endButton { display: inline-flex; width: 66px; height: 66px; cursor: pointer; align-items: center; justify-content: center; border: 0; border-radius: 50%; color: #fff; background: #e24456; box-shadow: 0 15px 34px rgba(226,68,86,.34); }
|
||||||
|
.topUpBackdrop { position: absolute; z-index: 10; inset: 0; display: flex; align-items: flex-end; justify-content: center; padding: 18px; background: rgba(5,4,9,.68); backdrop-filter: blur(8px); }
|
||||||
|
.topUpDialog { width: min(100%, 460px); padding: 24px; border: 1px solid rgba(255,255,255,.12); border-radius: 24px; background: #17131f; box-shadow: 0 24px 70px rgba(0,0,0,.5); text-align: center; }
|
||||||
|
.topUpDialog h2 { margin: 0; font-size: 21px; }
|
||||||
|
.topUpDialog p { margin: 10px 0 20px; color: rgba(255,255,255,.66); font-size: 14px; line-height: 1.5; }
|
||||||
|
.topUpDialog button { width: 100%; cursor: pointer; border: 0; border-radius: 999px; padding: 13px 18px; color: #fff; background: #df478d; font-weight: 750; }
|
||||||
|
.topUpDialog .secondaryButton { margin-top: 8px; color: rgba(255,255,255,.72); background: transparent; }
|
||||||
|
|
||||||
|
@keyframes callPulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.025); } }
|
||||||
|
|
||||||
|
@media (max-height: 700px) {
|
||||||
|
.avatarRing { width: 138px; height: 138px; }
|
||||||
|
.avatar { width: 124px; height: 124px; }
|
||||||
|
.content h1 { margin-top: 20px; }
|
||||||
|
.transcript { min-height: 72px; margin-top: 16px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Mic, PhoneOff, RotateCcw, Volume2, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
import { getApiConfig } from "@/core/net/config/api_config";
|
||||||
|
import {
|
||||||
|
VoiceCallTransport,
|
||||||
|
type CallBillingStatusPayload,
|
||||||
|
} from "@/core/net/voice-call-transport";
|
||||||
|
import { VoiceCallAudioCapture } from "@/integrations/voice-call-audio";
|
||||||
|
import { getSessionAccessToken } from "@/lib/auth/auth_session";
|
||||||
|
import { useActiveCharacter, useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
|
||||||
|
import { VoiceCallPlayer } from "./voice-call-player";
|
||||||
|
import styles from "./voice-call-screen.module.css";
|
||||||
|
|
||||||
|
type CallUiState = "idle" | "connecting" | "listening" | "thinking" | "speaking" | "ended" | "error";
|
||||||
|
|
||||||
|
const STATE_COPY: Record<CallUiState, string> = {
|
||||||
|
idle: "Ready to call",
|
||||||
|
connecting: "Connecting…",
|
||||||
|
listening: "Listening…",
|
||||||
|
thinking: "Thinking…",
|
||||||
|
speaking: "Speaking…",
|
||||||
|
ended: "Call ended",
|
||||||
|
error: "Call unavailable",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getSessionToken(): Promise<string> {
|
||||||
|
const token = await getSessionAccessToken();
|
||||||
|
if (token) return token;
|
||||||
|
throw new Error("Please sign in before starting a call");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceCallScreen() {
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
const routes = useActiveCharacterRoutes();
|
||||||
|
const navigator = useAppNavigator();
|
||||||
|
const [uiState, setUiState] = useState<CallUiState>("idle");
|
||||||
|
const [userTranscript, setUserTranscript] = useState("");
|
||||||
|
const [aiTranscript, setAiTranscript] = useState("");
|
||||||
|
const [freeTurns, setFreeTurns] = useState(0);
|
||||||
|
const [balance, setBalance] = useState(0);
|
||||||
|
const [cost, setCost] = useState(2);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showTopUp, setShowTopUp] = useState(false);
|
||||||
|
const transportRef = useRef<VoiceCallTransport | null>(null);
|
||||||
|
const captureRef = useRef<VoiceCallAudioCapture | null>(null);
|
||||||
|
const playerRef = useRef<VoiceCallPlayer | null>(null);
|
||||||
|
const callIdRef = useRef("");
|
||||||
|
const activeTurnRef = useRef("");
|
||||||
|
const reconnectTurnRef = useRef("");
|
||||||
|
const resumeVersionRef = useRef(0);
|
||||||
|
const endingRef = useRef(false);
|
||||||
|
const pendingTopUpRef = useRef(false);
|
||||||
|
|
||||||
|
const finishCall = useCallback((navigate = true) => {
|
||||||
|
if (endingRef.current) return;
|
||||||
|
endingRef.current = true;
|
||||||
|
captureRef.current?.dispose();
|
||||||
|
playerRef.current?.dispose();
|
||||||
|
if (callIdRef.current) transportRef.current?.endCall(callIdRef.current);
|
||||||
|
transportRef.current?.disconnect();
|
||||||
|
reconnectTurnRef.current = "";
|
||||||
|
resumeVersionRef.current = 0;
|
||||||
|
setUiState("ended");
|
||||||
|
if (navigate) navigator.push(routes.chat);
|
||||||
|
}, [navigator, routes.chat]);
|
||||||
|
|
||||||
|
const beginListening = useCallback((turnId: string) => {
|
||||||
|
const capture = captureRef.current;
|
||||||
|
const transport = transportRef.current;
|
||||||
|
const callId = callIdRef.current;
|
||||||
|
if (!capture || !transport || !callId || !turnId) return;
|
||||||
|
activeTurnRef.current = turnId;
|
||||||
|
reconnectTurnRef.current = turnId;
|
||||||
|
setUiState(playerRef.current?.isPlaying ? "speaking" : "listening");
|
||||||
|
const preRoll: ArrayBuffer[] = [];
|
||||||
|
let streamOpened = false;
|
||||||
|
capture.startTurn({
|
||||||
|
onChunk: (chunk) => {
|
||||||
|
if (streamOpened) {
|
||||||
|
transport.sendAudioChunk(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preRoll.push(chunk);
|
||||||
|
if (preRoll.length > 5) preRoll.shift();
|
||||||
|
},
|
||||||
|
onSpeechStart: () => {
|
||||||
|
setError(null);
|
||||||
|
if (playerRef.current?.isPlaying) {
|
||||||
|
playerRef.current.stop();
|
||||||
|
transport.interrupt(callId);
|
||||||
|
}
|
||||||
|
streamOpened = transport.startAudio(
|
||||||
|
callId,
|
||||||
|
turnId,
|
||||||
|
capture.mimeType,
|
||||||
|
capture.sampleRate,
|
||||||
|
);
|
||||||
|
if (streamOpened) {
|
||||||
|
for (const chunk of preRoll) transport.sendAudioChunk(chunk);
|
||||||
|
preRoll.length = 0;
|
||||||
|
setUserTranscript("");
|
||||||
|
setAiTranscript("");
|
||||||
|
setUiState("listening");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTurnEnd: (vadLatencyMs) => {
|
||||||
|
if (streamOpened) {
|
||||||
|
transport.endAudio(callId, turnId, vadLatencyMs);
|
||||||
|
setUiState("thinking");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (captureError) => {
|
||||||
|
setError(captureError.message);
|
||||||
|
setUiState("error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyBilling = useCallback((status: CallBillingStatusPayload) => {
|
||||||
|
setBalance(status.creditBalance);
|
||||||
|
setFreeTurns(status.freeTurnsRemaining);
|
||||||
|
setCost(status.requiredCredits || 2);
|
||||||
|
if (status.canContinue && status.nextTurnId) {
|
||||||
|
beginListening(status.nextTurnId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureRef.current?.stopTurn();
|
||||||
|
pendingTopUpRef.current = true;
|
||||||
|
if (!playerRef.current?.isPlaying) setShowTopUp(true);
|
||||||
|
}, [beginListening]);
|
||||||
|
|
||||||
|
const startCall = useCallback(async () => {
|
||||||
|
if (uiState !== "idle" && uiState !== "error") return;
|
||||||
|
endingRef.current = false;
|
||||||
|
pendingTopUpRef.current = false;
|
||||||
|
setError(null);
|
||||||
|
setUiState("connecting");
|
||||||
|
try {
|
||||||
|
const player = new VoiceCallPlayer();
|
||||||
|
playerRef.current = player;
|
||||||
|
const playerReady = player.prepare();
|
||||||
|
const capture = new VoiceCallAudioCapture();
|
||||||
|
await Promise.all([capture.prepare(), playerReady]);
|
||||||
|
captureRef.current = capture;
|
||||||
|
const token = await getSessionToken();
|
||||||
|
const transport = new VoiceCallTransport(getApiConfig().wsUrl, token);
|
||||||
|
transportRef.current = transport;
|
||||||
|
player.onPlaying = () => setUiState("speaking");
|
||||||
|
player.onIdle = () => {
|
||||||
|
if (pendingTopUpRef.current) setShowTopUp(true);
|
||||||
|
else if (activeTurnRef.current) setUiState("listening");
|
||||||
|
};
|
||||||
|
player.onError = (playbackError) => setError(playbackError.message);
|
||||||
|
player.onGap = (milliseconds) => {
|
||||||
|
transport.sendClientMetric("audioChunkGap", milliseconds);
|
||||||
|
};
|
||||||
|
transport.onOpen = () => {
|
||||||
|
if (!reconnectTurnRef.current) reconnectTurnRef.current = crypto.randomUUID();
|
||||||
|
transport.startCall(
|
||||||
|
character.id,
|
||||||
|
reconnectTurnRef.current,
|
||||||
|
resumeVersionRef.current || undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
transport.onReconnecting = () => {
|
||||||
|
capture.stopTurn();
|
||||||
|
player.stop();
|
||||||
|
callIdRef.current = "";
|
||||||
|
setUiState("connecting");
|
||||||
|
};
|
||||||
|
transport.onStarted = (started) => {
|
||||||
|
callIdRef.current = started.callId;
|
||||||
|
activeTurnRef.current = started.turnId;
|
||||||
|
reconnectTurnRef.current = started.turnId;
|
||||||
|
resumeVersionRef.current = started.resumeVersion;
|
||||||
|
setFreeTurns(started.freeTurnsRemaining);
|
||||||
|
setBalance(started.creditBalance);
|
||||||
|
setCost(started.costPerPaidTurn || 2);
|
||||||
|
beginListening(started.turnId);
|
||||||
|
};
|
||||||
|
transport.onState = setUiState;
|
||||||
|
transport.onTranscript = (text) => setUserTranscript(text);
|
||||||
|
transport.onAiSentence = (text) => {
|
||||||
|
setAiTranscript((current) => `${current}${current ? " " : ""}${text}`);
|
||||||
|
};
|
||||||
|
transport.onAudioChunk = (meta, bytes) => player.enqueue(meta, bytes);
|
||||||
|
transport.onBillingStatus = applyBilling;
|
||||||
|
transport.onEnded = () => setUiState("ended");
|
||||||
|
transport.onError = (code, message) => {
|
||||||
|
setError(message);
|
||||||
|
if (
|
||||||
|
activeTurnRef.current
|
||||||
|
&& (code === "CALL_TURN_FAILED" || code === "CALL_STT_FAILED")
|
||||||
|
) {
|
||||||
|
setUiState("listening");
|
||||||
|
} else {
|
||||||
|
setUiState("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
transport.connect();
|
||||||
|
} catch (startError) {
|
||||||
|
captureRef.current?.dispose();
|
||||||
|
playerRef.current?.dispose();
|
||||||
|
setError(startError instanceof Error ? startError.message : String(startError));
|
||||||
|
setUiState("error");
|
||||||
|
}
|
||||||
|
}, [applyBilling, beginListening, character.id, uiState]);
|
||||||
|
|
||||||
|
useEffect(() => () => finishCall(false), [finishCall]);
|
||||||
|
|
||||||
|
const lowBalance = freeTurns === 0 && balance < cost * 2 && balance >= cost;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileShell background="#090810">
|
||||||
|
<main className={styles.screen}>
|
||||||
|
<Image src={character.assets.chatBackground} alt="" fill priority className={styles.background} />
|
||||||
|
<div className={styles.scrim} />
|
||||||
|
<header className={styles.header}>
|
||||||
|
<button type="button" className={styles.iconButton} onClick={() => finishCall()} aria-label="Close call">
|
||||||
|
<X size={22} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div className={styles.balances}>
|
||||||
|
<span>{freeTurns} free</span>
|
||||||
|
<span>{balance} credits</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.content} aria-live="polite">
|
||||||
|
<div className={`${styles.avatarRing} ${styles[uiState]}`}>
|
||||||
|
<Image src={character.assets.avatar} alt={character.displayName} width={148} height={148} className={styles.avatar} priority />
|
||||||
|
</div>
|
||||||
|
<h1>{character.displayName}</h1>
|
||||||
|
<div className={styles.state}>
|
||||||
|
{uiState === "listening" ? <Mic size={18} aria-hidden="true" /> : null}
|
||||||
|
{uiState === "speaking" ? <Volume2 size={18} aria-hidden="true" /> : null}
|
||||||
|
<span>{STATE_COPY[uiState]}</span>
|
||||||
|
</div>
|
||||||
|
{lowBalance ? <p className={styles.lowBalance}>Your balance covers fewer than two paid turns.</p> : null}
|
||||||
|
<div className={styles.transcript}>
|
||||||
|
{userTranscript ? <p><span>You</span>{userTranscript}</p> : null}
|
||||||
|
{aiTranscript ? <p><span>{character.shortName}</span>{aiTranscript}</p> : null}
|
||||||
|
</div>
|
||||||
|
{error ? <p className={styles.error}>{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
{uiState === "idle" || uiState === "error" ? (
|
||||||
|
<button type="button" className={styles.startButton} onClick={() => void startCall()}>
|
||||||
|
{uiState === "error" ? <RotateCcw size={20} aria-hidden="true" /> : <Mic size={20} aria-hidden="true" />}
|
||||||
|
{uiState === "error" ? "Try again" : "Start call"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className={styles.endButton} onClick={() => finishCall()} aria-label="End call">
|
||||||
|
<PhoneOff size={27} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p>Speak naturally — you can interrupt at any time.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{showTopUp ? (
|
||||||
|
<div className={styles.topUpBackdrop} role="presentation">
|
||||||
|
<section className={styles.topUpDialog} role="dialog" aria-modal="true" aria-labelledby="call-topup-title">
|
||||||
|
<h2 id="call-topup-title">Not enough credits for another turn</h2>
|
||||||
|
<p>Your current reply has finished. Top up to keep the conversation going.</p>
|
||||||
|
<button type="button" onClick={() => navigator.openSubscription({ type: "topup", returnTo: "chat" })}>Top up credits</button>
|
||||||
|
<button type="button" className={styles.secondaryButton} onClick={() => finishCall()}>End call</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { VoiceCallScreen } from "@/app/call/voice-call-screen";
|
||||||
|
|
||||||
|
export default function CharacterCallPage() {
|
||||||
|
return <VoiceCallScreen />;
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ import type { ReactNode } from "react";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { getCharacterBySlug } from "@/data/constants/character";
|
import { getCharacterBySlug } from "@/data/constants/character";
|
||||||
import { PrivateZoomRouteProvider } from "@/providers/private-zoom-route-provider";
|
import { PrivateZoneRouteProvider } from "@/providers/private-zone-route-provider";
|
||||||
|
|
||||||
export default async function CharacterPrivateZoomLayout({
|
export default async function CharacterPrivateZoneLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -13,11 +13,11 @@ export default async function CharacterPrivateZoomLayout({
|
|||||||
}) {
|
}) {
|
||||||
const { characterSlug } = await params;
|
const { characterSlug } = await params;
|
||||||
const character = getCharacterBySlug(characterSlug);
|
const character = getCharacterBySlug(characterSlug);
|
||||||
if (!character?.capabilities.privateZoom) notFound();
|
if (!character?.capabilities.privateZone) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrivateZoomRouteProvider characterId={character.id}>
|
<PrivateZoneRouteProvider characterId={character.id}>
|
||||||
{children}
|
{children}
|
||||||
</PrivateZoomRouteProvider>
|
</PrivateZoneRouteProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrivateZoneScreen } from "@/app/private-zone/private-zone-screen";
|
||||||
|
|
||||||
|
export default function CharacterPrivateZonePage() {
|
||||||
|
return <PrivateZoneScreen />;
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PrivateZoomScreen } from "@/app/private-zoom/private-zoom-screen";
|
|
||||||
|
|
||||||
export default function CharacterPrivateZoomPage() {
|
|
||||||
return <PrivateZoomScreen />;
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,7 @@ export default async function CharacterTipPage({
|
|||||||
const initialPlanId = normalizeTipGiftParam(
|
const initialPlanId = normalizeTipGiftParam(
|
||||||
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
|
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
|
||||||
);
|
);
|
||||||
|
const chatActionId = getFirstPaymentSearchParam(query.chatActionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TipScreen
|
<TipScreen
|
||||||
@@ -30,6 +31,7 @@ export default async function CharacterTipPage({
|
|||||||
initialPlanId={initialPlanId}
|
initialPlanId={initialPlanId}
|
||||||
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
||||||
initialPayChannel={paymentReturn.initialPayChannel}
|
initialPayChannel={paymentReturn.initialPayChannel}
|
||||||
|
chatActionId={chatActionId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ChatAction } from "@/data/schemas/chat";
|
||||||
|
import type { PaymentOrderStatusResponse } from "@/data/schemas/payment";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveChatActionTarget,
|
||||||
|
type ChatActionNavigationContext,
|
||||||
|
} from "../chat-action-navigation";
|
||||||
|
|
||||||
|
const baseAction: ChatAction = {
|
||||||
|
actionId: "action-1",
|
||||||
|
kind: "support",
|
||||||
|
type: "openProfile",
|
||||||
|
copy: "Open it here.",
|
||||||
|
ctaLabel: "Open",
|
||||||
|
ruleId: null,
|
||||||
|
orderId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createContext(
|
||||||
|
order: Partial<PaymentOrderStatusResponse> = {},
|
||||||
|
): ChatActionNavigationContext {
|
||||||
|
const orderResponse: PaymentOrderStatusResponse = {
|
||||||
|
orderId: "order-1",
|
||||||
|
status: "pending",
|
||||||
|
orderType: "dol",
|
||||||
|
planId: "topup-100",
|
||||||
|
creditsAdded: 0,
|
||||||
|
...order,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
characterRoutes: {
|
||||||
|
splash: "/characters/elio/splash",
|
||||||
|
chat: "/characters/elio/chat",
|
||||||
|
call: "/characters/elio/call",
|
||||||
|
privateZone: "/characters/elio/private-zone",
|
||||||
|
tip: "/characters/elio/tip",
|
||||||
|
},
|
||||||
|
characterSlug: "elio",
|
||||||
|
profileUrl: "/profile?returnTo=chat",
|
||||||
|
feedbackUrl: "/feedback?returnTo=chat",
|
||||||
|
coinsRulesUrl: "/coins-rules?returnTo=chat",
|
||||||
|
getOrderStatus: vi.fn(async () => orderResponse),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveChatActionTarget", () => {
|
||||||
|
it.each([
|
||||||
|
["giftOffer", "/characters/elio/tip?chatActionId=action-1"],
|
||||||
|
["privateAlbumOffer", "/characters/elio/private-zone"],
|
||||||
|
["openProfile", "/profile?returnTo=chat"],
|
||||||
|
["openWallet", "/profile?returnTo=chat"],
|
||||||
|
["openCoinsRules", "/coins-rules?returnTo=chat"],
|
||||||
|
["openFeedback", "/feedback?returnTo=chat"],
|
||||||
|
] as const)("maps %s to an allowlisted internal page", async (type, expected) => {
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget({ ...baseAction, type }, createContext()),
|
||||||
|
).resolves.toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps VIP and top-up actions to typed subscription routes", async () => {
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(
|
||||||
|
{ ...baseAction, kind: "commercial", type: "activateVip" },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).resolves.toBe(
|
||||||
|
"/subscription?type=vip&returnTo=chat&character=elio&chatActionId=action-1",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(
|
||||||
|
{ ...baseAction, kind: "commercial", type: "topUp" },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).resolves.toBe(
|
||||||
|
"/subscription?type=topup&returnTo=chat&character=elio&chatActionId=action-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes a pending VIP order using its owned order id", async () => {
|
||||||
|
const action: ChatAction = {
|
||||||
|
...baseAction,
|
||||||
|
type: "resumePayment",
|
||||||
|
orderId: "order-1",
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(
|
||||||
|
action,
|
||||||
|
createContext({ orderType: "vip_monthly" }),
|
||||||
|
),
|
||||||
|
).resolves.toBe(
|
||||||
|
"/subscription?type=vip&returnTo=chat&character=elio&resumeOrderId=order-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resume an order that is no longer pending", async () => {
|
||||||
|
const action: ChatAction = {
|
||||||
|
...baseAction,
|
||||||
|
type: "resumePayment",
|
||||||
|
orderId: "order-1",
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(action, createContext({ status: "paid" })),
|
||||||
|
).resolves.toBe("/profile?returnTo=chat");
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(action, createContext({ status: "expired" })),
|
||||||
|
).resolves.toBe("/feedback?returnTo=chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { deriveChatSupportCta } from "../chat-support-cta";
|
||||||
|
|
||||||
|
describe("deriveChatSupportCta", () => {
|
||||||
|
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
||||||
|
|
||||||
|
it("shows first recharge ahead of a weaker role offer without inventing a timer", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: true,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "elio",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "firstRecharge",
|
||||||
|
label: "Support me · 50% OFF · First recharge",
|
||||||
|
commercialOfferId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a server-anchored 24-hour role offer countdown", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "maya-tan",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "commercialOffer",
|
||||||
|
label: "Support me · 30% OFF · 23:59:59",
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to Support me after an offer expires or is absent", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: null,
|
||||||
|
nowMs: now,
|
||||||
|
}).label,
|
||||||
|
).toBe("Support me");
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "elio",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-27T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}).label,
|
||||||
|
).toBe("Support me");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ChatAction } from "@/data/schemas/chat";
|
||||||
|
import type { PaymentOrderStatusResponse } from "@/data/schemas/payment";
|
||||||
|
import type { CharacterRoutes } from "@/router/routes";
|
||||||
|
import { appendRouteSearchParams, ROUTE_BUILDERS } from "@/router/routes";
|
||||||
|
|
||||||
|
export interface ChatActionNavigationContext {
|
||||||
|
characterRoutes: CharacterRoutes;
|
||||||
|
characterSlug: string;
|
||||||
|
profileUrl: string;
|
||||||
|
feedbackUrl: string;
|
||||||
|
coinsRulesUrl: string;
|
||||||
|
getOrderStatus: (orderId: string) => Promise<PaymentOrderStatusResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve only application-owned, allowlisted destinations for a chat action. */
|
||||||
|
export async function resolveChatActionTarget(
|
||||||
|
action: ChatAction,
|
||||||
|
context: ChatActionNavigationContext,
|
||||||
|
): Promise<string> {
|
||||||
|
switch (action.type) {
|
||||||
|
case "giftOffer":
|
||||||
|
return appendRouteSearchParams(context.characterRoutes.tip, {
|
||||||
|
chatActionId: action.actionId,
|
||||||
|
});
|
||||||
|
case "privateAlbumOffer":
|
||||||
|
return context.characterRoutes.privateZone;
|
||||||
|
case "openProfile":
|
||||||
|
case "openWallet":
|
||||||
|
return context.profileUrl;
|
||||||
|
case "openCoinsRules":
|
||||||
|
return context.coinsRulesUrl;
|
||||||
|
case "activateVip":
|
||||||
|
return ROUTE_BUILDERS.subscription("vip", {
|
||||||
|
sourceCharacterSlug: context.characterSlug,
|
||||||
|
returnTo: "chat",
|
||||||
|
chatActionId: action.actionId,
|
||||||
|
});
|
||||||
|
case "topUp":
|
||||||
|
return ROUTE_BUILDERS.subscription("topup", {
|
||||||
|
sourceCharacterSlug: context.characterSlug,
|
||||||
|
returnTo: "chat",
|
||||||
|
chatActionId: action.actionId,
|
||||||
|
});
|
||||||
|
case "openFeedback":
|
||||||
|
return context.feedbackUrl;
|
||||||
|
case "resumePayment": {
|
||||||
|
if (!action.orderId) return context.feedbackUrl;
|
||||||
|
const order = await context.getOrderStatus(action.orderId);
|
||||||
|
if (order.status === "paid") return context.profileUrl;
|
||||||
|
if (order.status !== "pending") return context.feedbackUrl;
|
||||||
|
if (order.orderType === "tip") return context.characterRoutes.tip;
|
||||||
|
return ROUTE_BUILDERS.subscription(
|
||||||
|
order.orderType === "vip" || order.orderType.startsWith("vip_")
|
||||||
|
? "vip"
|
||||||
|
: "topup",
|
||||||
|
{
|
||||||
|
sourceCharacterSlug: context.characterSlug,
|
||||||
|
returnTo: "chat",
|
||||||
|
resumeOrderId: action.orderId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, type CSSProperties } from "react";
|
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
@@ -17,7 +17,14 @@ import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
|||||||
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
|
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
|
||||||
import { buildGlobalPageUrl } from "@/router/global-route-context";
|
import { buildGlobalPageUrl } from "@/router/global-route-context";
|
||||||
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
|
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
|
||||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
import { getCharacterRoutes, ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
||||||
|
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
|
||||||
|
import { paymentApi } from "@/data/services/api";
|
||||||
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
|
import {
|
||||||
|
recordChatActionEvent,
|
||||||
|
rememberChatActionArrival,
|
||||||
|
} from "@/lib/chat/chat_action_events";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
|
||||||
@@ -26,8 +33,8 @@ import {
|
|||||||
ChatHeader,
|
ChatHeader,
|
||||||
ChatInputBar,
|
ChatInputBar,
|
||||||
ChatInsufficientCreditsBanner,
|
ChatInsufficientCreditsBanner,
|
||||||
|
ChatSupportButton,
|
||||||
ChatUnlockDialogs,
|
ChatUnlockDialogs,
|
||||||
FirstRechargeOfferBanner,
|
|
||||||
FullscreenImageViewer,
|
FullscreenImageViewer,
|
||||||
PwaInstallOverlay,
|
PwaInstallOverlay,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
@@ -39,12 +46,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
deriveIsGuest,
|
deriveIsGuest,
|
||||||
} from "./chat-screen.helpers";
|
} from "./chat-screen.helpers";
|
||||||
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
|
import { useChatSupportCta } from "./hooks/use-chat-support-cta";
|
||||||
|
import { useChatCommercialMessages } from "./hooks/use-chat-commercial-messages";
|
||||||
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
|
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
|
||||||
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
||||||
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
||||||
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
||||||
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
|
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
|
||||||
|
import { resolveChatActionTarget } from "./chat-action-navigation";
|
||||||
|
|
||||||
const chatShellStyle = {
|
const chatShellStyle = {
|
||||||
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
|
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
|
||||||
@@ -62,11 +71,21 @@ export function ChatScreen() {
|
|||||||
ROUTES.profile,
|
ROUTES.profile,
|
||||||
characterRoutes.chat,
|
characterRoutes.chat,
|
||||||
);
|
);
|
||||||
|
const feedbackUrl = buildGlobalPageUrl(
|
||||||
|
ROUTES.feedback,
|
||||||
|
characterRoutes.chat,
|
||||||
|
);
|
||||||
|
const coinsRulesUrl = buildGlobalPageUrl(
|
||||||
|
ROUTES.coinsRules,
|
||||||
|
characterRoutes.chat,
|
||||||
|
);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
|
const imageOpenedFromChatRef = useRef(false);
|
||||||
|
const imageViewerClosingRef = useRef(false);
|
||||||
useSplashLatestMessageSync({
|
useSplashLatestMessageSync({
|
||||||
characterId: state.characterId,
|
characterId: state.characterId,
|
||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
@@ -110,11 +129,17 @@ export function ChatScreen() {
|
|||||||
const messageLimitBanner = useChatMessageLimitBanner({
|
const messageLimitBanner = useChatMessageLimitBanner({
|
||||||
upgradePromptVisible: state.upgradePromptVisible,
|
upgradePromptVisible: state.upgradePromptVisible,
|
||||||
upgradeReason: state.upgradeReason,
|
upgradeReason: state.upgradeReason,
|
||||||
|
paymentGuidance: state.paymentGuidance,
|
||||||
});
|
});
|
||||||
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
|
const supportCta = useChatSupportCta({
|
||||||
|
characterId: character.id,
|
||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
|
useChatCommercialMessages({
|
||||||
|
characterId: character.id,
|
||||||
|
loginStatus: authState.loginStatus,
|
||||||
|
});
|
||||||
const shouldShowPwaInstall =
|
const shouldShowPwaInstall =
|
||||||
state.historyLoaded && state.historyMessages.length >= 10;
|
state.historyLoaded && state.historyMessages.length >= 10;
|
||||||
useChatGuestLogin({
|
useChatGuestLogin({
|
||||||
@@ -124,6 +149,13 @@ export function ChatScreen() {
|
|||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageMessageId) {
|
||||||
|
imageOpenedFromChatRef.current = false;
|
||||||
|
imageViewerClosingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [imageMessageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!imageMessageId ||
|
!imageMessageId ||
|
||||||
@@ -215,6 +247,7 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenImage(displayMessageId: string): void {
|
function handleOpenImage(displayMessageId: string): void {
|
||||||
|
imageOpenedFromChatRef.current = true;
|
||||||
router.push(
|
router.push(
|
||||||
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
||||||
{ scroll: false },
|
{ scroll: false },
|
||||||
@@ -222,6 +255,13 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCloseImageViewer(): void {
|
function handleCloseImageViewer(): void {
|
||||||
|
if (imageViewerClosingRef.current) return;
|
||||||
|
imageViewerClosingRef.current = true;
|
||||||
|
if (imageOpenedFromChatRef.current) {
|
||||||
|
imageOpenedFromChatRef.current = false;
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.replace(
|
router.replace(
|
||||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||||
{ scroll: false },
|
{ scroll: false },
|
||||||
@@ -246,8 +286,126 @@ export function ChatScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenCharacterPrivateZoom(): void {
|
function handleOpenCharacterPrivateZone(): void {
|
||||||
router.push(characterRoutes.privateZoom);
|
router.push(characterRoutes.privateZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommercialAction(action: CommercialAction): Promise<void> {
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.commercial_action.open",
|
||||||
|
action.ctaLabel,
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
ruleId: action.ruleId,
|
||||||
|
target: action.target,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (action.target === "discountConsent") {
|
||||||
|
const offer = await paymentApi.acceptCommercialOffer(action.actionId);
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.commercial_action.accepted",
|
||||||
|
action.ctaLabel,
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
planId: offer.planId,
|
||||||
|
discountPercent: offer.discountPercent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.push(
|
||||||
|
ROUTE_BUILDERS.subscription(offer.subscriptionType, {
|
||||||
|
sourceCharacterSlug: character.slug,
|
||||||
|
returnTo: "chat",
|
||||||
|
planId: offer.planId,
|
||||||
|
commercialOfferId: offer.commercialOfferId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(
|
||||||
|
action.target === "giftCatalog"
|
||||||
|
? characterRoutes.tip
|
||||||
|
: characterRoutes.privateZone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommercialActionDismiss(action: CommercialAction): void {
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.commercial_action.dismiss",
|
||||||
|
"Dismiss commercial action",
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
ruleId: action.ruleId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChatActionViewed(action: ChatAction): void {
|
||||||
|
void recordChatActionEvent(action, state.characterId, "viewed").catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChatAction(action: ChatAction): Promise<void> {
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.action.open",
|
||||||
|
action.ctaLabel,
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionKind: action.kind,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
ruleId: action.ruleId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await recordChatActionEvent(
|
||||||
|
action,
|
||||||
|
state.characterId,
|
||||||
|
"opened",
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
|
const targetUrl = await resolveChatActionTarget(action, {
|
||||||
|
characterRoutes,
|
||||||
|
characterSlug: character.slug,
|
||||||
|
profileUrl,
|
||||||
|
feedbackUrl,
|
||||||
|
coinsRulesUrl,
|
||||||
|
getOrderStatus: (orderId) => paymentApi.getOrderStatus(orderId),
|
||||||
|
});
|
||||||
|
const authenticatedTarget = resolveAuthenticatedNavigation({
|
||||||
|
loginStatus: authState.loginStatus,
|
||||||
|
targetUrl,
|
||||||
|
});
|
||||||
|
rememberChatActionArrival(
|
||||||
|
action,
|
||||||
|
state.characterId,
|
||||||
|
targetUrl,
|
||||||
|
);
|
||||||
|
router.push(authenticatedTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChatActionDismiss(action: ChatAction): void {
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.action.dismiss",
|
||||||
|
"Dismiss chat action",
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionKind: action.kind,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
ruleId: action.ruleId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
void recordChatActionEvent(
|
||||||
|
action,
|
||||||
|
state.characterId,
|
||||||
|
"dismissed",
|
||||||
|
).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -270,13 +428,13 @@ export function ChatScreen() {
|
|||||||
<ChatHeader
|
<ChatHeader
|
||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
offerBanner={
|
offerBanner={
|
||||||
<FirstRechargeOfferBanner
|
supportCta.visible ? (
|
||||||
visible={firstRechargeOfferBanner.visible}
|
<ChatSupportButton
|
||||||
discountPercent={firstRechargeOfferBanner.discountPercent}
|
kind={supportCta.kind}
|
||||||
onClick={firstRechargeOfferBanner.claim}
|
label={supportCta.label}
|
||||||
onClose={firstRechargeOfferBanner.close}
|
onClick={supportCta.open}
|
||||||
variant="compact"
|
|
||||||
/>
|
/>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -297,7 +455,12 @@ export function ChatScreen() {
|
|||||||
onUnlockImageMessage={handleUnlockImageMessage}
|
onUnlockImageMessage={handleUnlockImageMessage}
|
||||||
onOpenImage={handleOpenImage}
|
onOpenImage={handleOpenImage}
|
||||||
onUserAvatarClick={handleOpenUserProfile}
|
onUserAvatarClick={handleOpenUserProfile}
|
||||||
onCharacterAvatarClick={handleOpenCharacterPrivateZoom}
|
onCharacterAvatarClick={handleOpenCharacterPrivateZone}
|
||||||
|
onCommercialAction={handleCommercialAction}
|
||||||
|
onCommercialActionDismiss={handleCommercialActionDismiss}
|
||||||
|
onChatActionViewed={handleChatActionViewed}
|
||||||
|
onChatAction={handleChatAction}
|
||||||
|
onChatActionDismiss={handleChatActionDismiss}
|
||||||
onLoadMoreHistory={() => {
|
onLoadMoreHistory={() => {
|
||||||
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
|
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
|
||||||
}}
|
}}
|
||||||
@@ -308,6 +471,7 @@ export function ChatScreen() {
|
|||||||
title={messageLimitBanner.title}
|
title={messageLimitBanner.title}
|
||||||
description={messageLimitBanner.description}
|
description={messageLimitBanner.description}
|
||||||
ctaLabel={messageLimitBanner.ctaLabel}
|
ctaLabel={messageLimitBanner.ctaLabel}
|
||||||
|
guidance={messageLimitBanner.guidance}
|
||||||
onUnlock={messageLimitBanner.unlock}
|
onUnlock={messageLimitBanner.unlock}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CommercialOfferSummary } from "@/data/schemas/payment";
|
||||||
|
|
||||||
|
export type ChatSupportCtaKind =
|
||||||
|
| "support"
|
||||||
|
| "firstRecharge"
|
||||||
|
| "commercialOffer";
|
||||||
|
|
||||||
|
export interface ChatSupportCtaView {
|
||||||
|
kind: ChatSupportCtaKind;
|
||||||
|
label: string;
|
||||||
|
commercialOfferId: string | null;
|
||||||
|
planId: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveChatSupportCta(input: {
|
||||||
|
isFirstRecharge: boolean;
|
||||||
|
commercialOffer: CommercialOfferSummary | null;
|
||||||
|
nowMs: number;
|
||||||
|
}): ChatSupportCtaView {
|
||||||
|
if (input.isFirstRecharge) {
|
||||||
|
return {
|
||||||
|
kind: "firstRecharge",
|
||||||
|
label: "Support me · 50% OFF · First recharge",
|
||||||
|
commercialOfferId: null,
|
||||||
|
planId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = input.commercialOffer;
|
||||||
|
const expiresAtMs = offer ? Date.parse(offer.expiresAt) : Number.NaN;
|
||||||
|
if (offer?.enabled && Number.isFinite(expiresAtMs) && expiresAtMs > input.nowMs) {
|
||||||
|
return {
|
||||||
|
kind: "commercialOffer",
|
||||||
|
label: `Support me · ${offer.discountPercent}% OFF · ${formatOfferCountdown(expiresAtMs, input.nowMs)}`,
|
||||||
|
commercialOfferId: offer.commercialOfferId,
|
||||||
|
planId: offer.planId,
|
||||||
|
expiresAt: offer.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "support",
|
||||||
|
label: "Support me",
|
||||||
|
commercialOfferId: null,
|
||||||
|
planId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOfferCountdown(expiresAtMs: number, nowMs: number): string {
|
||||||
|
const totalSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((expiresAtMs - nowMs) / 1000) - 1,
|
||||||
|
);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return [hours, minutes, seconds]
|
||||||
|
.map((value) => String(value).padStart(2, "0"))
|
||||||
|
.join(":");
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ChatActionCard } from "../chat-action-card";
|
||||||
|
|
||||||
|
describe("ChatActionCard", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports one view, opens the action and can be dismissed", async () => {
|
||||||
|
const onViewed = vi.fn();
|
||||||
|
const onActivate = vi.fn(async () => undefined);
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const action = {
|
||||||
|
actionId: "action-1",
|
||||||
|
kind: "support" as const,
|
||||||
|
type: "openFeedback" as const,
|
||||||
|
copy: "Share the screenshot and approximate payment time here.",
|
||||||
|
ctaLabel: "Open Feedback",
|
||||||
|
ruleId: null,
|
||||||
|
orderId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ChatActionCard
|
||||||
|
action={action}
|
||||||
|
onViewed={onViewed}
|
||||||
|
onActivate={onActivate}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(onViewed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container.querySelector("[data-chat-action='openFeedback']"))
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const openButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent?.includes("Open Feedback"),
|
||||||
|
);
|
||||||
|
await act(async () => openButton?.click());
|
||||||
|
expect(onActivate).toHaveBeenCalledWith(action);
|
||||||
|
|
||||||
|
const dismissButton = container.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Dismiss"]',
|
||||||
|
);
|
||||||
|
act(() => dismissButton?.click());
|
||||||
|
expect(onDismiss).toHaveBeenCalledWith(action);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -124,6 +124,36 @@ describe("ChatInputBar", () => {
|
|||||||
expect(getButton("Open chat actions")).not.toBeNull();
|
expect(getButton("Open chat actions")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses the Facebook WebView click that follows a pointer send for 300ms", () => {
|
||||||
|
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||||
|
renderBar();
|
||||||
|
setTextareaValue(getTextarea(), "Hello Maya");
|
||||||
|
const sendButton = getButton("Send message");
|
||||||
|
const pointerDown = new Event("pointerdown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(pointerDown, "pointerType", { value: "touch" });
|
||||||
|
|
||||||
|
act(() => sendButton.dispatchEvent(pointerDown));
|
||||||
|
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||||
|
type: "ChatSendMessage",
|
||||||
|
content: "Hello Maya",
|
||||||
|
});
|
||||||
|
expect(getTextarea().value).toBe("");
|
||||||
|
expect(getButton("Open chat actions")).toBe(sendButton);
|
||||||
|
|
||||||
|
now.mockReturnValue(1_299);
|
||||||
|
act(() => sendButton.click());
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
|
||||||
|
|
||||||
|
now.mockReturnValue(1_300);
|
||||||
|
act(() => sendButton.click());
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("closes the menu on outside interaction and when disabled", () => {
|
it("closes the menu on outside interaction and when disabled", () => {
|
||||||
renderBar();
|
renderBar();
|
||||||
act(() => getButton("Open chat actions").click());
|
act(() => getButton("Open chat actions").click());
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { CommercialActionCard } from "../commercial-action-card";
|
||||||
|
|
||||||
|
describe("CommercialActionCard", () => {
|
||||||
|
it("renders a private-zone offer with a usable CTA", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CommercialActionCard
|
||||||
|
action={{
|
||||||
|
actionId: "action-1",
|
||||||
|
type: "privateAlbumOffer",
|
||||||
|
copy: "There are more private photos waiting for you.",
|
||||||
|
ctaLabel: "Open private zone",
|
||||||
|
target: "privateZone",
|
||||||
|
ruleId: "private_album_after_appearance_praise",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('data-commercial-action="privateAlbumOffer"');
|
||||||
|
expect(html).toContain("There are more private photos waiting for you.");
|
||||||
|
expect(html).toContain("Open private zone");
|
||||||
|
expect(html).toContain('aria-label="Dismiss offer"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a consent-first discount action without a checkout URL", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CommercialActionCard
|
||||||
|
action={{
|
||||||
|
actionId: "offer-1",
|
||||||
|
type: "discountOffer",
|
||||||
|
copy: "Want me to ask for my best private offer?",
|
||||||
|
ctaLabel: "Yes, ask for me",
|
||||||
|
target: "discountConsent",
|
||||||
|
ruleId: "discount_after_price_objection",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('data-commercial-action="discountOffer"');
|
||||||
|
expect(html).toContain("Yes, ask for me");
|
||||||
|
expect(html).not.toContain("/subscription");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type Mock,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
|
||||||
|
import { FullscreenImageViewer } from "../fullscreen-image-viewer";
|
||||||
|
|
||||||
|
describe("FullscreenImageViewer", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let onClose: Mock<() => void>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
onClose = vi.fn<() => void>();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FullscreenImageViewer
|
||||||
|
characterId="elio-silvestri"
|
||||||
|
remoteMessageId="message-1"
|
||||||
|
imageUrl="/chat-image.png"
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes exactly once when the fullscreen image is tapped", () => {
|
||||||
|
const image = container.querySelector("img");
|
||||||
|
expect(image).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => image?.click());
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes exactly once from the visible back button", () => {
|
||||||
|
const backButton = container.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Back"]',
|
||||||
|
);
|
||||||
|
expect(backButton).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => backButton?.click());
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes from the backdrop and Escape key", () => {
|
||||||
|
const viewer = container.querySelector<HTMLDivElement>(
|
||||||
|
'[aria-label="Fullscreen image"]',
|
||||||
|
);
|
||||||
|
expect(viewer).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => viewer?.click());
|
||||||
|
act(() =>
|
||||||
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import { ImageBubble } from "../image-bubble";
|
|||||||
import { InsufficientCreditsDialog } from "../insufficient-credits-dialog";
|
import { InsufficientCreditsDialog } from "../insufficient-credits-dialog";
|
||||||
import { MessageAvatar } from "../message-avatar";
|
import { MessageAvatar } from "../message-avatar";
|
||||||
import { PrivateMessageCard } from "../private-message-card";
|
import { PrivateMessageCard } from "../private-message-card";
|
||||||
|
import { PaymentGuidanceCard } from "../payment-guidance-card";
|
||||||
import { PwaInstallDialog } from "../pwa-install-dialog";
|
import { PwaInstallDialog } from "../pwa-install-dialog";
|
||||||
import { TextBubble } from "../text-bubble";
|
import { TextBubble } from "../text-bubble";
|
||||||
|
|
||||||
@@ -62,10 +63,10 @@ describe("chat Tailwind components", () => {
|
|||||||
|
|
||||||
expect(aiHtml).toContain('<button type="button"');
|
expect(aiHtml).toContain('<button type="button"');
|
||||||
expect(aiHtml).toContain(
|
expect(aiHtml).toContain(
|
||||||
'data-analytics-key="chat.open_private_zoom_from_avatar"',
|
'data-analytics-key="chat.open_private_zone_from_avatar"',
|
||||||
);
|
);
|
||||||
expect(aiHtml).toContain(
|
expect(aiHtml).toContain(
|
||||||
'aria-label="Open Elio Silvestri's private zoom"',
|
'aria-label="Open Elio Silvestri's private zone"',
|
||||||
);
|
);
|
||||||
expect(userHtml).toContain('<button type="button"');
|
expect(userHtml).toContain('<button type="button"');
|
||||||
expect(userHtml).toContain(
|
expect(userHtml).toContain(
|
||||||
@@ -223,6 +224,19 @@ describe("chat Tailwind components", () => {
|
|||||||
description="Credits keep the conversation going."
|
description="Credits keep the conversation going."
|
||||||
ctaLabel="Top up"
|
ctaLabel="Top up"
|
||||||
onUnlock={() => undefined}
|
onUnlock={() => undefined}
|
||||||
|
guidance={{
|
||||||
|
guidanceId: "guidance-elio",
|
||||||
|
characterId: "elio",
|
||||||
|
characterName: "Elio Silvestri",
|
||||||
|
scene: "insufficientCredits",
|
||||||
|
mode: "guide",
|
||||||
|
title: "Keep talking with Elio",
|
||||||
|
copy: "Baby, I want to keep talking with you. Adding credits supports Elio.",
|
||||||
|
ctaLabel: "View VIP & credit options",
|
||||||
|
purchaseOptions: ["vip", "topup"],
|
||||||
|
target: "subscription",
|
||||||
|
ruleId: "payment_guidance_insufficientCredits",
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -232,11 +246,56 @@ describe("chat Tailwind components", () => {
|
|||||||
expect(html).toContain("<br/>");
|
expect(html).toContain("<br/>");
|
||||||
expect(html).toContain("Top up now");
|
expect(html).toContain("Top up now");
|
||||||
expect(html).toContain("Credits keep the conversation going.");
|
expect(html).toContain("Credits keep the conversation going.");
|
||||||
|
expect(html).toContain("supports Elio");
|
||||||
|
expect(html).toContain('data-payment-guidance-id="guidance-elio"');
|
||||||
expect(html).toContain('aria-label="Top up"');
|
expect(html).toContain('aria-label="Top up"');
|
||||||
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
|
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
|
||||||
expect(html).toContain("active:scale-98");
|
expect(html).toContain("active:scale-98");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders guidance only for the active character and hides care-only CTA", () => {
|
||||||
|
const guide = {
|
||||||
|
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
|
||||||
|
characterId: "elio",
|
||||||
|
characterName: "Elio Silvestri",
|
||||||
|
scene: "insufficientCredits" as const,
|
||||||
|
mode: "guide" as const,
|
||||||
|
title: "Keep talking with Elio",
|
||||||
|
copy: "Baby, I want to keep talking with you.",
|
||||||
|
ctaLabel: "View VIP & credit options",
|
||||||
|
purchaseOptions: ["vip", "topup"] as const,
|
||||||
|
target: "subscription" as const,
|
||||||
|
ruleId: "payment_guidance_insufficientCredits",
|
||||||
|
};
|
||||||
|
const guideHtml = renderWithCharacter(
|
||||||
|
<PaymentGuidanceCard guidance={guide} />,
|
||||||
|
);
|
||||||
|
const mismatchHtml = renderWithCharacter(
|
||||||
|
<PaymentGuidanceCard
|
||||||
|
guidance={{ ...guide, characterId: "maya-tan", characterName: "Maya Tan" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const careHtml = renderWithCharacter(
|
||||||
|
<PaymentGuidanceCard
|
||||||
|
guidance={{
|
||||||
|
...guide,
|
||||||
|
mode: "careOnly",
|
||||||
|
title: null,
|
||||||
|
ctaLabel: null,
|
||||||
|
purchaseOptions: [],
|
||||||
|
target: null,
|
||||||
|
copy: "Please keep money you need for food or medicine.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(guideHtml).toContain('data-payment-guidance-mode="guide"');
|
||||||
|
expect(guideHtml).toContain("View VIP & credit options");
|
||||||
|
expect(mismatchHtml).toBe("");
|
||||||
|
expect(careHtml).toContain("food or medicine");
|
||||||
|
expect(careHtml).not.toContain("View VIP");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders ChatHeader guest and member branches", () => {
|
it("renders ChatHeader guest and member branches", () => {
|
||||||
const guestHtml = renderWithCharacter(<ChatHeader isGuest={true} />);
|
const guestHtml = renderWithCharacter(<ChatHeader isGuest={true} />);
|
||||||
const memberHtml = renderWithCharacter(
|
const memberHtml = renderWithCharacter(
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CircleDollarSign,
|
||||||
|
CircleHelp,
|
||||||
|
Gift,
|
||||||
|
Images,
|
||||||
|
LoaderCircle,
|
||||||
|
MessageSquareWarning,
|
||||||
|
UserRound,
|
||||||
|
WalletCards,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import type { ChatAction } from "@/data/schemas/chat";
|
||||||
|
|
||||||
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
|
const ACTION_ICONS: Record<ChatAction["type"], LucideIcon> = {
|
||||||
|
giftOffer: Gift,
|
||||||
|
privateAlbumOffer: Images,
|
||||||
|
openProfile: UserRound,
|
||||||
|
openWallet: WalletCards,
|
||||||
|
openCoinsRules: WalletCards,
|
||||||
|
activateVip: CircleDollarSign,
|
||||||
|
topUp: CircleDollarSign,
|
||||||
|
openFeedback: MessageSquareWarning,
|
||||||
|
resumePayment: CircleHelp,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChatActionCardProps {
|
||||||
|
action: ChatAction;
|
||||||
|
onViewed?: (action: ChatAction) => void;
|
||||||
|
onActivate?: (action: ChatAction) => void | Promise<void>;
|
||||||
|
onDismiss?: (action: ChatAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatActionCard({
|
||||||
|
action,
|
||||||
|
onViewed,
|
||||||
|
onActivate,
|
||||||
|
onDismiss,
|
||||||
|
}: ChatActionCardProps) {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [activating, setActivating] = useState(false);
|
||||||
|
const [activationError, setActivationError] = useState<string | null>(null);
|
||||||
|
const viewedActionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewedActionIdRef.current === action.actionId) return;
|
||||||
|
viewedActionIdRef.current = action.actionId;
|
||||||
|
onViewed?.(action);
|
||||||
|
}, [action, onViewed]);
|
||||||
|
|
||||||
|
if (dismissed) return null;
|
||||||
|
const Icon = ACTION_ICONS[action.type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={styles.commercialAction}
|
||||||
|
aria-label={action.ctaLabel}
|
||||||
|
data-chat-action={action.type}
|
||||||
|
data-chat-action-kind={action.kind}
|
||||||
|
>
|
||||||
|
<div className={styles.commercialActionHeader}>
|
||||||
|
<Icon size={18} aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.commercialActionDismiss}
|
||||||
|
title="Dismiss"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onClick={() => {
|
||||||
|
setDismissed(true);
|
||||||
|
onDismiss?.(action);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className={styles.commercialActionCopy}>{action.copy}</p>
|
||||||
|
{activationError ? (
|
||||||
|
<p className={styles.commercialActionError} role="alert">
|
||||||
|
{activationError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.commercialActionButton}
|
||||||
|
disabled={activating}
|
||||||
|
onClick={() => {
|
||||||
|
setActivating(true);
|
||||||
|
setActivationError(null);
|
||||||
|
void Promise.resolve(onActivate?.(action))
|
||||||
|
.catch(() =>
|
||||||
|
setActivationError(
|
||||||
|
"This option is unavailable right now. Please try again.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.finally(() => setActivating(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{action.ctaLabel}</span>
|
||||||
|
{activating ? (
|
||||||
|
<LoaderCircle
|
||||||
|
className={styles.commercialActionSpinner}
|
||||||
|
size={17}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ArrowRight size={17} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -131,6 +131,84 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.commercialAction {
|
||||||
|
width: min(100%, 286px);
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(25, 21, 31, 0.92);
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionHeader {
|
||||||
|
display: flex;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #f4b860;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionDismiss {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.66);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionCopy {
|
||||||
|
margin: 7px 0 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: var(--responsive-caption, var(--font-size-sm, 13px));
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionButton {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 38px;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f4b860;
|
||||||
|
color: #201710;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--responsive-caption, var(--font-size-sm, 13px));
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionButton span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionButton:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionError {
|
||||||
|
margin: -4px 0 10px;
|
||||||
|
color: #ffb4ab;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionSpinner {
|
||||||
|
animation: commercial-action-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes commercial-action-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.bubbleAi {
|
.bubbleAi {
|
||||||
max-width: var(--chat-bubble-max-width, 75%);
|
max-width: var(--chat-bubble-max-width, 75%);
|
||||||
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
import type { UiMessage } from "@/stores/chat/ui-message";
|
||||||
|
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
|
||||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +62,11 @@ export interface ChatAreaProps {
|
|||||||
onLoadMoreHistory?: () => void;
|
onLoadMoreHistory?: () => void;
|
||||||
onUserAvatarClick?: () => void;
|
onUserAvatarClick?: () => void;
|
||||||
onCharacterAvatarClick?: () => void;
|
onCharacterAvatarClick?: () => void;
|
||||||
|
onCommercialAction?: (action: CommercialAction) => void;
|
||||||
|
onCommercialActionDismiss?: (action: CommercialAction) => void;
|
||||||
|
onChatActionViewed?: (action: ChatAction) => void;
|
||||||
|
onChatAction?: (action: ChatAction) => void | Promise<void>;
|
||||||
|
onChatActionDismiss?: (action: ChatAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessageAction = (
|
type ChatMessageAction = (
|
||||||
@@ -85,6 +91,11 @@ export function ChatArea({
|
|||||||
onLoadMoreHistory,
|
onLoadMoreHistory,
|
||||||
onUserAvatarClick,
|
onUserAvatarClick,
|
||||||
onCharacterAvatarClick,
|
onCharacterAvatarClick,
|
||||||
|
onCommercialAction,
|
||||||
|
onCommercialActionDismiss,
|
||||||
|
onChatActionViewed,
|
||||||
|
onChatAction,
|
||||||
|
onChatActionDismiss,
|
||||||
}: ChatAreaProps) {
|
}: ChatAreaProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -257,6 +268,11 @@ export function ChatArea({
|
|||||||
onOpenImage,
|
onOpenImage,
|
||||||
onUserAvatarClick,
|
onUserAvatarClick,
|
||||||
onCharacterAvatarClick,
|
onCharacterAvatarClick,
|
||||||
|
onCommercialAction,
|
||||||
|
onCommercialActionDismiss,
|
||||||
|
onChatActionViewed,
|
||||||
|
onChatAction,
|
||||||
|
onChatActionDismiss,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReplyingAI ? (
|
{isReplyingAI ? (
|
||||||
@@ -295,6 +311,11 @@ function renderMessagesWithDateHeaders(
|
|||||||
onOpenImage?: (displayMessageId: string) => void,
|
onOpenImage?: (displayMessageId: string) => void,
|
||||||
onUserAvatarClick?: () => void,
|
onUserAvatarClick?: () => void,
|
||||||
onCharacterAvatarClick?: () => void,
|
onCharacterAvatarClick?: () => void,
|
||||||
|
onCommercialAction?: (action: CommercialAction) => void,
|
||||||
|
onCommercialActionDismiss?: (action: CommercialAction) => void,
|
||||||
|
onChatActionViewed?: (action: ChatAction) => void,
|
||||||
|
onChatAction?: (action: ChatAction) => void | Promise<void>,
|
||||||
|
onChatActionDismiss?: (action: ChatAction) => void,
|
||||||
) {
|
) {
|
||||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||||
item.type === "date" ? (
|
item.type === "date" ? (
|
||||||
@@ -314,6 +335,9 @@ function renderMessagesWithDateHeaders(
|
|||||||
lockReason={item.message.lockReason}
|
lockReason={item.message.lockReason}
|
||||||
lockedPrivate={item.message.lockedPrivate}
|
lockedPrivate={item.message.lockedPrivate}
|
||||||
privateMessageHint={item.message.privateMessageHint}
|
privateMessageHint={item.message.privateMessageHint}
|
||||||
|
commercialAction={item.message.commercialAction}
|
||||||
|
chatAction={item.message.chatAction}
|
||||||
|
paymentGuidance={item.message.paymentGuidance}
|
||||||
isUnlockingMessage={
|
isUnlockingMessage={
|
||||||
isUnlockingMessage === true &&
|
isUnlockingMessage === true &&
|
||||||
item.message.displayId === unlockingMessageId
|
item.message.displayId === unlockingMessageId
|
||||||
@@ -324,6 +348,11 @@ function renderMessagesWithDateHeaders(
|
|||||||
onOpenImage={onOpenImage}
|
onOpenImage={onOpenImage}
|
||||||
onUserAvatarClick={onUserAvatarClick}
|
onUserAvatarClick={onUserAvatarClick}
|
||||||
onCharacterAvatarClick={onCharacterAvatarClick}
|
onCharacterAvatarClick={onCharacterAvatarClick}
|
||||||
|
onCommercialAction={onCommercialAction}
|
||||||
|
onCommercialActionDismiss={onCommercialActionDismiss}
|
||||||
|
onChatActionViewed={onChatActionViewed}
|
||||||
|
onChatAction={onChatAction}
|
||||||
|
onChatActionDismiss={onChatActionDismiss}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||